mato 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: cc964ed48f7d5e7d1d069e32b2ea3701118a6765
4
+ data.tar.gz: 310ca6e0226f7ebaf1da5653d17a926580c8c476
5
+ SHA512:
6
+ metadata.gz: b19a063ae37c6885ae08a1e1d961a3519f36dc19fd42370a09f95a143f5a1b3f84d5d17adb17f48e8247db268d8750f0c1759a54193e6f7c93e73696a08b45ba
7
+ data.tar.gz: 1b6d1995d00dba709bd52d8138530ce8fe66699b5006060dc10277333569a23582fec03ab0f86a47380e3eee5328fbc6739b841152d5a5a27219d3f842ffca80
@@ -0,0 +1,10 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /tmp/
10
+ .idea/
@@ -0,0 +1,139 @@
1
+ # See also https://github.com/onk/onkcop/blob/master/config/rubocop.yml
2
+
3
+ AllCops:
4
+ TargetRubyVersion: 2.3
5
+ DisplayCopNames: true
6
+ Exclude:
7
+ - bin/**/*
8
+
9
+ Lint/UnusedBlockArgument:
10
+ Enabled: false
11
+
12
+ Lint/AmbiguousBlockAssociation:
13
+ Enabled: false
14
+
15
+ Lint/ScriptPermission:
16
+ Enabled: false
17
+
18
+ Layout/MultilineMethodCallIndentation:
19
+ Enabled: false
20
+
21
+ Layout/EmptyLinesAroundClassBody:
22
+ Enabled: false
23
+
24
+ Metrics/AbcSize:
25
+ Max: 32
26
+
27
+ Metrics/PerceivedComplexity:
28
+ Max: 10
29
+
30
+ Metrics/CyclomaticComplexity:
31
+ Max: 10
32
+
33
+ Metrics/ClassLength:
34
+ Enabled: false
35
+
36
+ Metrics/BlockLength:
37
+ Enabled: false
38
+
39
+ Metrics/LineLength:
40
+ Enabled: false
41
+
42
+ Metrics/MethodLength:
43
+ Max: 45
44
+
45
+ Metrics/ModuleLength:
46
+ Enabled: false
47
+
48
+ Metrics/ParameterLists:
49
+ Enabled: false
50
+
51
+ Style/BlockDelimiters:
52
+ Enabled: false
53
+
54
+ Style/CommentAnnotation:
55
+ Enabled: false
56
+
57
+ Style/Documentation:
58
+ Enabled: false
59
+
60
+ Style/FileName:
61
+ Enabled: false
62
+
63
+ Style/GuardClause:
64
+ Enabled: false
65
+
66
+ Style/IfInsideElse:
67
+ Enabled: false
68
+
69
+ Style/IfUnlessModifier:
70
+ Enabled: false
71
+
72
+ Style/Lambda:
73
+ Enabled: false
74
+
75
+ Style/MutableConstant:
76
+ Enabled: false
77
+
78
+ Style/NumericPredicate:
79
+ Enabled: false
80
+
81
+ Style/PercentLiteralDelimiters:
82
+ Enabled: false
83
+
84
+ Style/SignalException:
85
+ Enabled: false
86
+
87
+ Style/StringLiterals:
88
+ Enabled: false
89
+
90
+ Style/TrailingCommaInLiteral:
91
+ EnforcedStyleForMultiline: comma
92
+
93
+ Style/TrailingCommaInArguments:
94
+ EnforcedStyleForMultiline: comma
95
+
96
+ Style/WordArray:
97
+ Enabled: false
98
+
99
+ Style/NumericLiterals:
100
+ Enabled: false
101
+
102
+ Style/MultilineBlockChain:
103
+ Enabled: false
104
+
105
+ Style/ConditionalAssignment:
106
+ Enabled: false
107
+
108
+ Style/RescueModifier:
109
+ Enabled: false
110
+
111
+ Style/AsciiComments:
112
+ Enabled: false
113
+
114
+ Style/EmptyMethod:
115
+ EnforcedStyle: expanded
116
+
117
+ Style/VariableNumber:
118
+ Enabled: false
119
+
120
+ Style/PredicateName:
121
+ Enabled: false
122
+
123
+ Style/AccessorMethodName:
124
+ Enabled: false
125
+
126
+ Style/YodaCondition:
127
+ Enabled: false
128
+
129
+ Style/FormatStringToken:
130
+ Enabled: false
131
+
132
+ Style/MultipleComparison:
133
+ Enabled: false
134
+
135
+ Style/StructInheritance:
136
+ Enabled: false
137
+
138
+ Performance/RedundantBlockCall:
139
+ Enabled: false
@@ -0,0 +1,9 @@
1
+ sudo: false
2
+ language: ruby
3
+ rvm:
4
+ - 2.3
5
+ - 2.4
6
+ before_install: gem install bundler
7
+ notifications:
8
+ slack:
9
+ secure: W/CuFdMffK19NqZ7hX5Cx1ekLwY5WYtyi91CgYJ5jUYZq6hY7sZAHItfvbB7kwVc5vGzDVzBp1L4+iR44OX1+3sbJQ8OHUfWNksRME5NAHeHKUnkeoMAToTy6gv8GTSDOkBoMxgtZQQwaFFxOVQF4UknLCKvwz1JRBf3zjEG7zyKlBQuYSkMQQm4xA/qmgb6nCAqSzMJxN4Gl57LKphCScKO5ZK6hUuaJv4H6y3NfDbKAEwidEUaMqnU1TtLTkqhSqVve/JMlBoynYFIezBu7R35cyHFdE8l1OaYFvIJo6nsyRZr9RVhp20wlLyL2dsM4IUBTfLf1dCw94U8VE3cCkYEh7MxQv/NAkDpkBsI9E4GGOfmuXRxcoQGoKRI9GHleg8hm/E39134JhVKNL2L5RGg/8ULNPPrQjMf0GWSxhtqBXN7vzuP4ZfAMzEDDGFehCmmzrE5qIdcRwXCI3+QRX7XAWL+Ql+xljoxZjl2f7wHSx3COChkol0V8CEGAyOTJc3qnci5jPs5mSByqv3RyO0meYiJCJ1Ce2FTjCOHs1G9D58xfra6AVA8FmOk43+iN8QZyUIqd+MsenL2k89LqMijoC208xI6WI88C9tMHDA8tmi8yJt6PA00emC+Gzy9xPCYsZ+dZhymOPDcAbBlez+j3weEEDROF0Jlewt4X8Q=
data/Gemfile ADDED
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ # Specify your gem's dependencies in mato.gemspec
6
+ gemspec
7
+
8
+ gem "sanitize"
9
+ gem "rouge"
10
+
11
+ gem "m"
12
+ gem "minitest"
13
+ gem "minitest-power_assert"
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2017 FUJI Goro (gfx)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
@@ -0,0 +1,104 @@
1
+ # Mato - Markdown Toolkit based on CommonMark [![Build Status](https://travis-ci.org/bitjourney/mato.svg?branch=master)](https://travis-ci.org/bitjourney/mato)
2
+
3
+ **Mato**, standing for **Ma**rkdown **To**oolkit, is an extensible markdown-based content processing toolkit, inspired by [HTML::Pipeline](https://github.com/jch/html-pipeline).
4
+
5
+
6
+ This gem is built on [commonmarker](https://github.com/gjtorikian/commonmarker), a [CommonMark](https://github.com/jgm/CommonMark) implementation,
7
+ which can parse markdown documents into AST.
8
+
9
+ ## Synopsis
10
+
11
+ ```ruby
12
+ require 'mato'
13
+
14
+ # define a markdown-to-html processor:
15
+ mato = Mato.define do |config|
16
+ # append pre-defined HTML filters:
17
+ config.append_html_filter(Mato::HtmlFilters::SyntaxHighlight.new)
18
+ config.append_html_filter(Mato::HtmlFilters::TaskList.new)
19
+ config.append_html_filter(Mato::HtmlFilters::SectionAnchor.new)
20
+
21
+ # append MentionLink, a customizable HTML filter:
22
+ config.append_html_filter(Mato::HtmlFilters::MentionLink.new do |mention_candidate_map|
23
+ candidate_accounts = mention_candidate_map.keys.map { |name| name.gsub(/^\@/, '') }
24
+ User.where(account: candidate_accounts).each do |user|
25
+ mention = "@#{user.account}"
26
+ mention_candidate_map[mention].each do |node|
27
+ node.replace("<a href='https://twitter.com/#{mention}' class='mention'>#{mention}</a>")
28
+ end
29
+ end
30
+ end)
31
+ end
32
+
33
+ # Prosesses markdown into Mato::Document:
34
+ doc = mato.process(markdown_content)
35
+
36
+ # Renders doc as HTML:
37
+ html = doc.render_html
38
+
39
+ # Renders doc as HTML Table of Contents:
40
+ html_toc = doc.render_html_toc
41
+
42
+ # Extracts elements (e.g. mentions) with CSS selector:
43
+ doc.css('a').each do |element|
44
+ # do something with element: Nokogiri::XML::Element
45
+ end
46
+
47
+ # Extracts nodes (e.g. mentions) with XPath selector:
48
+ doc.xpath('./text()').each do |node|
49
+ # do something with node: Nokogiri::XML::Text
50
+ end
51
+
52
+ # Mato::Document can be cached with Rails.cache (i.e. Marshal.dump ready)
53
+ Rails.fetch(digest(markdown_content)) do
54
+ mato.process(markdown_content)
55
+ end
56
+
57
+ # Applies extra filters and returns a new Mato::Document.
58
+ # Because Mato::Document is serializable, you can cache the base doc and then apply extra filters on demaond.
59
+ new_doc = doc.apply_html_filters(
60
+ -> (fragment) { modify_fragment!(fragment) },
61
+ SomeHtmlFilter.new, # anything that has #call(node) method
62
+ )
63
+ ```
64
+
65
+ ## Installation
66
+
67
+ Add this line to your application's Gemfile:
68
+
69
+ ```ruby:Gemfile
70
+ gem 'mato'
71
+ ```
72
+
73
+ And then execute:
74
+
75
+ $ bundle
76
+
77
+ Or install it yourself as:
78
+
79
+ $ gem install mato
80
+
81
+ ## Usage
82
+
83
+ TODO: Write usage instructions here
84
+
85
+ ## Optional Dependencies
86
+
87
+ * [rouge](https://github.com/jneen/rouge) (>= 2.0) to use `SyntaxHighlight`
88
+ * [sanitize](https://github.com/rgrove/sanitize) (>= 4.0) to use `Sanitization`
89
+
90
+ ## Development
91
+
92
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
93
+
94
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
95
+
96
+ ## Contributing
97
+
98
+ Bug reports and pull requests are welcome on GitHub at https://github.com/bitjourney/mato.
99
+
100
+
101
+ ## License
102
+
103
+ The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
104
+
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << "test"
8
+ t.libs << "lib"
9
+ t.warning = false
10
+ t.test_files = FileList['test/**/*_test.rb']
11
+ end
12
+
13
+ task default: :test
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "mato"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start(__FILE__)
@@ -0,0 +1,8 @@
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
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'mato'
5
+
6
+ # User class that mocks ActiveRecord's
7
+ User = Struct.new(:account)
8
+ class User
9
+ VALID_ACCOUNTS = %w(mato)
10
+
11
+ # @return [Enumerable<User>]
12
+ def self.where(account:)
13
+ (account & VALID_ACCOUNTS).map do |name|
14
+ User.new(name)
15
+ end
16
+ end
17
+ end
18
+
19
+ mato = Mato.define do |config|
20
+ config.append_text_filter ->(text, _context) {
21
+ # weave text
22
+ }
23
+
24
+ config.append_markdown_filter ->(doc, _context) {
25
+ # weave doc
26
+ }
27
+
28
+ config.append_html_filter ->(doc, _context) {
29
+ # weave doc
30
+ }
31
+
32
+ config.append_html_filter(Mato::HtmlFilters::TaskList.new)
33
+ config.append_html_filter(Mato::HtmlFilters::MentionLink.new do |mention_candidate_map|
34
+ candidate_accounts = mention_candidate_map.keys.map { |name| name.gsub(/^\@/, '') }
35
+ User.where(account: candidate_accounts).each do |user|
36
+ mention = "@#{user.account}"
37
+ mention_candidate_map[mention].each do |node|
38
+ node.replace("<a href='https://twitter.com/#{mention}' class='mention'>#{mention}</a>")
39
+ end
40
+ end
41
+ end)
42
+ end
43
+
44
+ puts mato.process(<<~'MARKDOWN').render_html
45
+ # Hello, @mato!
46
+
47
+ @this_is_invalid_mention
48
+
49
+ https://twitter.com/@kibe_la
50
+
51
+ * [ ] a
52
+ * [x] b
53
+ MARKDOWN
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'mato'
5
+
6
+ mato = Mato.define do |config|
7
+ config.append_html_filter(Mato::HtmlFilters::SectionAnchor.new)
8
+ end
9
+
10
+ puts mato.process(<<~'MARKDOWN').render_html_toc
11
+ # **First** Level Title
12
+
13
+ ## **Second** Level Title
14
+
15
+ ### **Third** Level Title
16
+
17
+ ##### **Fifth** Level Title
18
+
19
+ # **First** Level Title Again
20
+ MARKDOWN
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ # core classes
4
+ require_relative "./mato/version"
5
+ require_relative "./mato/config"
6
+ require_relative "./mato/processor"
7
+
8
+ # filter classes
9
+ require_relative "./mato/html_filters/token_link"
10
+ require_relative "./mato/html_filters/mention_link"
11
+ require_relative "./mato/html_filters/syntax_highlight"
12
+ require_relative "./mato/html_filters/task_list"
13
+ require_relative "./mato/html_filters/section_anchor"
14
+ require_relative "./mato/html_filters/sanitization"
15
+
16
+ module Mato
17
+ # @param [Proc] block
18
+ # @yieldparam [Mato::Config] config
19
+ # @return [Mato::Processor]
20
+ def self.define(&block)
21
+ config = Mato::Config.new
22
+ config.configure(&block)
23
+ Mato::Processor.new(config)
24
+ end
25
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+
5
+ module Mato
6
+ class AnchorBuilder
7
+
8
+ # assumes use of font-awesome
9
+ # specify it as "<span aria-hidden=\"true\" class=\"octicon octicon-link\"></span>" if you use octicon
10
+ DEFAULT_ANCHOR_ICON_ELEMENT = '<i class="fa fa-link"></i>'
11
+
12
+ CSS_CLASS_NAME = "anchor"
13
+
14
+ attr_reader :anchor_icon_element
15
+ attr_reader :context
16
+
17
+ def initialize(anchor_icon_element = DEFAULT_ANCHOR_ICON_ELEMENT)
18
+ @anchor_icon_element = anchor_icon_element
19
+ @id_map = {}
20
+ end
21
+
22
+ # @param [Nokogiri::XML::Node] hx
23
+ def make_anchor_element(hx)
24
+ id = make_anchor_id(hx)
25
+ %{<a id="#{id}" href="##{id}" aria-hidden="true" class="#{CSS_CLASS_NAME}">#{anchor_icon_element}</a>}
26
+ end
27
+
28
+ def make_anchor_id(hx)
29
+ prefix = make_anchor_id_prefix(hx.inner_text)
30
+ "#{prefix}#{make_anchor_id_suffix(prefix)}"
31
+ end
32
+
33
+ private
34
+
35
+ def make_anchor_id_suffix(text)
36
+ @id_map[text] ||= -1
37
+ unique_id = @id_map[text] += 1
38
+
39
+ if unique_id > 0
40
+ "-#{unique_id}"
41
+ else
42
+ ""
43
+ end
44
+ end
45
+
46
+ def make_anchor_id_prefix(text)
47
+ ERB::Util.url_encode(text.downcase.gsub(/[^\p{Word}\- ]/u, "").tr(" ", "-"))
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mato
4
+ module Concerns
5
+ module HtmlNodeCheckable
6
+ module_function
7
+
8
+ # @param [Nokogiri::XML::Node] node
9
+ # @param [Array<String>] tags - set of tags
10
+ # @return [Boolean] true if the node has the specified tags as a parent
11
+ def has_ancestor?(node, *tags)
12
+ current = node
13
+ while (current = current.parent)
14
+ if tags.include?(current.name)
15
+ return true
16
+ end
17
+ end
18
+ false
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative('./document')
4
+
5
+ require 'nokogiri'
6
+
7
+ module Mato
8
+ class Config
9
+ # https://github.com/gjtorikian/commonmarker#parse-options
10
+ DEFAULT_MARKDOWN_PARSE_OPTIONS = %i[
11
+ DEFAULT
12
+ VALIDATE_UTF8
13
+ ]
14
+
15
+ # https://github.com/gjtorikian/commonmarker#render-options
16
+ DEFAULT_MARKDOWN_RENDER_OPTIONS = [
17
+ :DEFAULT,
18
+ :HARDBREAKS, # convert "\n" as <br/>
19
+ # :SOURCEPOS, // TODO: enable it after assertions are supported
20
+ ]
21
+
22
+ # https://github.com/github/cmark/tree/master/extensions
23
+ DEFAULT_MARKDOWN_EXTENSIONS = %i[
24
+ table
25
+ strikethrough
26
+ autolink
27
+ tagfilter
28
+ ]
29
+
30
+ # @return [Array<Proc>]
31
+ attr_accessor :text_filters
32
+
33
+ # @return [Array<Proc>]
34
+ attr_accessor :markdown_filters
35
+
36
+ # @return [Array<Proc>]
37
+ attr_accessor :html_filters
38
+
39
+ # @return [Class<CommonMarker]
40
+ attr_accessor :markdown_parser
41
+
42
+ # @return [Cass<Nokogiri::HTML::DocumentFragment>]
43
+ attr_accessor :html_parser
44
+
45
+ # @return [Class<Mato::Document>]
46
+ attr_accessor :document_factory
47
+
48
+ # @return [Array<Symbol>] CommonMarker's parse extensions
49
+ attr_accessor :markdown_extensions
50
+
51
+ # @return [Array<Symbol>] CommonMarker's pars options
52
+ attr_accessor :markdown_parse_options
53
+
54
+ # @return [Array<Symbol>] CommonMarker's HTML rendering options
55
+ attr_accessor :markdown_render_options
56
+
57
+ def initialize
58
+ @text_filters = []
59
+ @markdown_filters = []
60
+ @html_filters = []
61
+
62
+ @markdown_parser = CommonMarker
63
+ @html_parser = Nokogiri::HTML::DocumentFragment
64
+
65
+ @document_factory = Document
66
+
67
+ @markdown_extensions = DEFAULT_MARKDOWN_EXTENSIONS
68
+ @markdown_parse_options = DEFAULT_MARKDOWN_PARSE_OPTIONS
69
+ @markdown_render_options = DEFAULT_MARKDOWN_RENDER_OPTIONS
70
+ end
71
+
72
+ # @param [Proc] block
73
+ # @yieldparam [Mato::Config] config
74
+ def configure(&block)
75
+ block.call(self)
76
+ end
77
+
78
+ def append_text_filter(text_filter)
79
+ raise "text_filter must respond to call()" unless text_filter.respond_to?(:call)
80
+ text_filters.push(text_filter)
81
+ end
82
+
83
+ def prepend_text_filter(text_filter)
84
+ raise "text_filter must respond to call()" unless text_filter.respond_to?(:call)
85
+ text_filters.unshift(text_filter)
86
+ end
87
+
88
+ def append_markdown_filter(markdown_filter)
89
+ raise "markdown_filter must respond to call()" unless markdown_filter.respond_to?(:call)
90
+ markdown_filters.push(markdown_filter)
91
+ end
92
+
93
+ def prepend_markdown_filter(markdown_filter)
94
+ raise "markdown_filter must respond to call()" unless markdown_filter.respond_to?(:call)
95
+ markdown_filters.unshift(markdown_filter)
96
+ end
97
+
98
+ def append_html_filter(html_filter)
99
+ raise "html_filter must respond to call()" unless html_filter.respond_to?(:call)
100
+ html_filters.push(html_filter)
101
+ end
102
+
103
+ def prepend_html_filter(html_filter)
104
+ raise "html_filter must respond to call()" unless html_filter.respond_to?(:call)
105
+ html_filters.unshift(html_filter)
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,65 @@
1
+
2
+ # frozen_string_literal: true
3
+
4
+ require_relative './renderers/html_renderer'
5
+ require_relative './renderers/html_toc_renderer'
6
+
7
+ # Intermediate document class, which instance is *serializable*.
8
+ module Mato
9
+ class Document
10
+ # @return [Nokogiri::HTML::DocumentFragment]
11
+ attr_reader :fragment
12
+
13
+ def self.empty
14
+ new(Nokogiri::HTML.fragment(''))
15
+ end
16
+
17
+ # @param [Nokogiri::HTML::DocumentFragment] fragment
18
+ def initialize(fragment)
19
+ @fragment = fragment
20
+ end
21
+
22
+ # @return [Nokogiri::HTML::DocumentFragment] A copy of fragment that are modified by html_filters
23
+ def apply_html_filters(*html_filters)
24
+ new_fragment = fragment.dup
25
+ html_filters.each do |html_filter|
26
+ html_filter.call(new_fragment)
27
+ end
28
+ self.class.new(new_fragment.freeze)
29
+ end
30
+
31
+ # @param [String] selector
32
+ # @return [Nokogiri::XML::NodeSet]
33
+ def css(selector)
34
+ fragment.css(selector)
35
+ end
36
+
37
+ # @param [String] query
38
+ # @return [Nokogiri::XML::NodeSet]
39
+ def xpath(query)
40
+ fragment.xpath(query)
41
+ end
42
+
43
+ def render(renderer)
44
+ renderer.call(fragment)
45
+ end
46
+
47
+ def render_html
48
+ render(Mato::Renderers::HtmlRenderer.new)
49
+ end
50
+
51
+ def render_html_toc
52
+ render(Mato::Renderers::HtmlTocRenderer.new)
53
+ end
54
+
55
+ def marshal_dump
56
+ {
57
+ fragment: fragment.to_s,
58
+ }
59
+ end
60
+
61
+ def marshal_load(data)
62
+ initialize(Nokogiri::HTML.fragment(data[:fragment]).freeze)
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../concerns/html_node_checkable'
4
+
5
+ module Mato
6
+ module HtmlFilters
7
+ class MentionLink
8
+ include Concerns::HtmlNodeCheckable
9
+
10
+ # @return [Regexp]
11
+ attr_reader :pattern
12
+
13
+ # @return [Proc]
14
+ attr_reader :link_builder
15
+
16
+ MENTION_PATTERN = /\@[a-zA-Z0-9_]+\b/ # e.g. @foo
17
+
18
+ # @param [Regexp] pattern
19
+ # @param [Proc] link_builder A block that takes Hash<String, Array<Nokogiri::XML::Node>>
20
+ def initialize(pattern = MENTION_PATTERN, &link_builder)
21
+ @pattern = pattern
22
+ @link_builder = link_builder
23
+ end
24
+
25
+ # @param [Nokogiri::HTML::DocumentFragment] doc
26
+ def call(doc)
27
+ candidate_map = {}
28
+ candidates = []
29
+
30
+ doc.xpath('.//text()').each do |text_node|
31
+ next if has_ancestor?(text_node, 'a', 'code', 'pre')
32
+
33
+ candidate_html = text_node.content.gsub(pattern) do |mention|
34
+ "<span class='mention-candidate'>#{mention}</span>"
35
+ end
36
+
37
+ next if text_node.content == candidate_html
38
+
39
+ candidate_fragment = text_node.replace(candidate_html)
40
+ candidate_fragment.css('span').each do |mention_element|
41
+ (candidate_map[mention_element.child.content] ||= []) << mention_element
42
+ end
43
+
44
+ candidates << candidate_fragment
45
+ end
46
+
47
+ unless candidate_map.empty?
48
+ link_builder.call(candidate_map)
49
+
50
+ # cleanup
51
+ candidates.each do |candidate_fragment|
52
+ candidate_fragment.css('span.mention-candidate').each do |node|
53
+ node.replace(node.child.content)
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ # note: do require 'sanitize' by yourself
4
+
5
+ # https://github.com/rgrove/sanitize
6
+ module Mato
7
+ module HtmlFilters
8
+ class Sanitization
9
+ attr_reader :sanitize
10
+
11
+ # @param [Sanitize] sanitize
12
+ def initialize(sanitize:)
13
+ @sanitize = sanitize
14
+ end
15
+
16
+ def call(doc)
17
+ sanitize.node!(doc)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../anchor_builder'
4
+
5
+ module Mato
6
+ module HtmlFilters
7
+ class SectionAnchor
8
+
9
+ HX_PATTERN = 'h1,h2,h3,h4,h5,h6'
10
+
11
+ def initialize(anchor_icon_element = AnchorBuilder::DEFAULT_ANCHOR_ICON_ELEMENT)
12
+ @anchor_icon_element = anchor_icon_element
13
+ end
14
+
15
+ # @param [Nokogiri::HTML::DocumentFragment] doc
16
+ def call(doc)
17
+ anchor_builder = AnchorBuilder.new(@anchor_icon_element)
18
+
19
+ doc.css(HX_PATTERN).each do |hx|
20
+ hx.children = anchor_builder.make_anchor_element(hx) + hx.children.to_html
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ # do require 'rouge' by yourself
4
+
5
+ module Mato
6
+ module HtmlFilters
7
+ class SyntaxHighlight
8
+
9
+ # @param [Nokogiri::HTML::DocumentFragment] doc
10
+ def call(doc)
11
+ doc.search("pre").each do |pre|
12
+ if pre.at('code')
13
+ pre.replace(highlight(pre))
14
+ end
15
+ end
16
+ end
17
+
18
+ # @param [String] language
19
+ # @param [String] filename
20
+ # @param [String] source
21
+ # @return [Rouge::Lexer]
22
+ def guess_lexer(language, filename, source)
23
+ Rouge::Lexer.find(language)&.tap do |lexer|
24
+ return lexer.new
25
+ end
26
+
27
+ lexers = Rouge::Lexer.guesses(filename: filename, source: source)
28
+
29
+ if lexers.empty?
30
+ Rouge::Lexers::PlainText.new
31
+ else
32
+ lexers.first.new
33
+ end
34
+ end
35
+
36
+ # @param [String,nil] CSS class names, e.g. "foo.js" "ruby:foo.rb"
37
+ def parse_label(class_name)
38
+ a = class_name&.split(/:/) || []
39
+ if a.empty?
40
+ {}
41
+ elsif a.size == 1
42
+ token = a[0].sub(/^language-/, '')
43
+ if Rouge::Lexer.find(token)
44
+ { language: token }
45
+ else
46
+ { filename: token }
47
+ end
48
+ else
49
+ { language: a[0], filename: a[1] }
50
+ end
51
+ end
52
+
53
+ private
54
+
55
+ # @param [Nokogiri::XML::Element] pre a <pre/> element
56
+ # @return [Nokogiri::XML::Element] a new <div/> wrapping the given code block
57
+ def highlight(pre)
58
+ code = pre.at('code')
59
+ metadata = parse_label(code['class'])
60
+ language = metadata[:language]
61
+ filename = metadata[:filename]
62
+ source = code.inner_text
63
+
64
+ lexer = guess_lexer(language, filename, source)
65
+
66
+ document = Nokogiri::HTML.fragment(%{<div class="code-frame"/>})
67
+
68
+ div = document.at('div')
69
+ div.add_child(label_fragment(filename || language || lexer.tag)) if filename || !lexer.is_a?(Rouge::Lexers::PlainText)
70
+ div.add_child(%{<pre class="highlight"><code data-lang="#{lexer.tag}">#{format(lexer, source)}</code></pre>})
71
+
72
+ document
73
+ end
74
+
75
+ def label_fragment(label)
76
+ Nokogiri::HTML.fragment(%{<div class="code-label"/>}).tap do |fragment|
77
+ fragment.at('div').add_child(Nokogiri::XML::Text.new(label, fragment))
78
+ end
79
+ end
80
+
81
+ def formatter
82
+ @formatter ||= Rouge::Formatters::HTML.new
83
+ end
84
+
85
+ def format(lexer, source)
86
+ tokens = lexer.lex(source)
87
+ formatter.format(tokens)
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mato
4
+ module HtmlFilters
5
+ class TaskList
6
+ CHECKED_MARK = "[x] "
7
+ UNCHECKED_MARK = "[ ] "
8
+
9
+ DEFAULT_TASK_LIST_CLASS = "task-list-item"
10
+ DEFAULT_CHECKBOX_CLASS = "task-list-item-checkbox"
11
+
12
+ def initialize(task_list_class: DEFAULT_TASK_LIST_CLASS, checkbox_class: DEFAULT_CHECKBOX_CLASS)
13
+ @task_list_class = task_list_class
14
+ @checkbox_class = checkbox_class
15
+ end
16
+
17
+ # @param [Nokogiri::HTML::DocumentFragment] doc
18
+ def call(doc)
19
+ doc.search("li").each do |li|
20
+ weave(li)
21
+ end
22
+ end
23
+
24
+ # @param [Nokogiri::XML::Node] li
25
+ def weave(li)
26
+ text_node = li.xpath('.//text()').first
27
+ checked = has_checked_mark?(text_node)
28
+ unchecked = has_unchecked_mark?(text_node)
29
+
30
+ return unless checked || unchecked
31
+
32
+ li["class"] = @task_list_class
33
+
34
+ text_node.content = trim_mark(text_node.content, checked)
35
+ checkbox = build_checkbox_node(checked)
36
+ text_node.add_previous_sibling(checkbox)
37
+ end
38
+
39
+ def has_checked_mark?(text_node)
40
+ text_node&.content&.start_with?(CHECKED_MARK)
41
+ end
42
+
43
+ def has_unchecked_mark?(text_node)
44
+ text_node&.content&.start_with?(UNCHECKED_MARK)
45
+ end
46
+
47
+ def trim_mark(content, checked)
48
+ if checked
49
+ content.sub(CHECKED_MARK, '')
50
+ else
51
+ content.sub(UNCHECKED_MARK, '')
52
+ end
53
+ end
54
+
55
+ def build_checkbox_node(checked)
56
+ Nokogiri::HTML.fragment('<input type="checkbox"/>').tap do |fragment|
57
+ checkbox = fragment.children.first
58
+ checkbox["class"] = @checkbox_class
59
+ checkbox["disabled"] = 'disabled'
60
+ checkbox["checked"] = 'checked' if checked
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../concerns/html_node_checkable'
4
+
5
+ module Mato
6
+ module HtmlFilters
7
+ class TokenLink
8
+ include Concerns::HtmlNodeCheckable
9
+
10
+ # @return [Regexp]
11
+ attr_reader :pattern
12
+
13
+ # @return [Proc]
14
+ attr_reader :builder
15
+
16
+ # @param [Regexp] pattern
17
+ # @param [Procc] builder link builder that takes
18
+ def initialize(pattern, &builder)
19
+ @pattern = pattern
20
+ @builder = builder
21
+ end
22
+
23
+ # @param [Nokogiri::HTML::DocumentFragment] doc
24
+ def call(doc)
25
+ doc.xpath('.//text()').each do |text_node|
26
+ next if has_ancestor?(text_node, 'a', 'code')
27
+
28
+ text_node.replace(text_node.content.gsub(pattern, &builder))
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative('./document')
4
+
5
+ require 'nokogiri'
6
+ require 'commonmarker'
7
+
8
+ module Mato
9
+ class Processor
10
+ # @return [Mato::Config]
11
+ attr_reader :config
12
+
13
+ def initialize(config)
14
+ @config = config
15
+ end
16
+
17
+ # @param [String] input
18
+ # @return [Mato::Document]
19
+ def process(input)
20
+ text = input.dup
21
+
22
+ config.text_filters.each do |filter|
23
+ filter.call(text)
24
+ end
25
+
26
+ markdown_node = parse_markdown(text)
27
+
28
+ config.markdown_filters.each do |filter|
29
+ filter.call(markdown_node)
30
+ end
31
+
32
+ html = render_to_html(markdown_node)
33
+ doc = parse_html(html)
34
+
35
+ config.html_filters.each do |filter|
36
+ filter.call(doc)
37
+ end
38
+
39
+ config.document_factory.new(doc.freeze)
40
+ end
41
+
42
+ # @param [String] text
43
+ # @return [CommonMarker::Node]
44
+ def parse_markdown(text)
45
+ config.markdown_parser.render_doc(text, config.markdown_parse_options, config.markdown_extensions)
46
+ end
47
+
48
+ # @param [CommonMarker::Node] markdown_node
49
+ # @return [String]
50
+ def render_to_html(markdown_node)
51
+ markdown_node.to_html(config.markdown_render_options)
52
+ end
53
+
54
+ # @param [String] html
55
+ # @return [Nokogiri::HTML::DocumentFragment]
56
+ def parse_html(html)
57
+ config.html_parser.parse(html)
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mato
4
+ module Renderers
5
+ class HtmlRenderer
6
+ # @param [Nokogiri::HTML::DocumentFragment] doc
7
+ # @return [String]
8
+ def call(doc)
9
+ doc.to_html
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../anchor_builder'
4
+
5
+ module Mato
6
+ module Renderers
7
+ class HtmlTocRenderer
8
+
9
+ H_SELECTOR = %w(h1 h2 h3 h4 h5 h6).join(',')
10
+
11
+ # @param [Nokogiri::HTML::DocumentFragment] doc
12
+ def call(doc)
13
+ s = +''
14
+
15
+ stack = [0]
16
+
17
+ doc.css(H_SELECTOR).each do |hx|
18
+ h_level = level(hx)
19
+ if h_level > stack.last
20
+ stack.push(h_level)
21
+ s << %{<ul>\n}
22
+ elsif h_level < stack.last
23
+ while stack.last > h_level
24
+ s << %{</li></ul>\n}
25
+ stack.pop
26
+ end
27
+ end
28
+
29
+ first_child = hx.child
30
+ if anchor?(first_child)
31
+ s << %{<li><a href="##{first_child['id']}">}
32
+
33
+ child = first_child.next_sibling
34
+ while child
35
+ s << child.to_html
36
+ child = child.next_sibling
37
+ end
38
+
39
+ s << %{</a>}
40
+ else
41
+ s << %{<li>#{hx.children}}
42
+ end
43
+ end
44
+
45
+ while stack.last != 0
46
+ stack.pop
47
+ s << %{</li></ul>\n}
48
+ end
49
+
50
+ s
51
+ end
52
+
53
+ private
54
+
55
+ # @param [Nokogiri::XML::Node] node
56
+ def level(node)
57
+ /\d+/.match(node.name)[0].to_i
58
+ end
59
+
60
+ # @param [Nokogiri::XML::Node] node
61
+ def anchor?(node)
62
+ node.name == 'a' && node['class'] == AnchorBuilder::CSS_CLASS_NAME
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mato
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative './lib/mato/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "mato"
7
+ spec.version = Mato::VERSION
8
+ spec.authors = ["FUJI Goro"]
9
+ spec.email = ["goro-fuj@bitjoureny.com"]
10
+
11
+ spec.summary = 'MArkdown TOolkit'
12
+ spec.description = 'An extensible markdown processing toolkit based on CommonMark'
13
+ spec.homepage = "https://github.com/bitjourney/mato"
14
+ spec.license = "MIT"
15
+
16
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
17
+ f.match(%r{^(test|spec|features)/})
18
+ end
19
+ spec.bindir = "exe"
20
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
21
+ spec.require_paths = ["lib"]
22
+
23
+ spec.required_ruby_version = ">= 2.3"
24
+
25
+ spec.add_runtime_dependency "nokogiri", ">= 1.6"
26
+ spec.add_runtime_dependency "commonmarker", ">= 0.14"
27
+
28
+ # spec.add_optional_dependency "sanitize", ">= 3.0"
29
+ # spec.add_optional_dependency "rouge", ">= 2.0"
30
+
31
+ spec.add_development_dependency "bundler", ">= 1.14"
32
+ spec.add_development_dependency "rake", ">= 10.0"
33
+ end
metadata ADDED
@@ -0,0 +1,127 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mato
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - FUJI Goro
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2017-08-03 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: nokogiri
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '1.6'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '1.6'
27
+ - !ruby/object:Gem::Dependency
28
+ name: commonmarker
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0.14'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0.14'
41
+ - !ruby/object:Gem::Dependency
42
+ name: bundler
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '1.14'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '1.14'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rake
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '10.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '10.0'
69
+ description: An extensible markdown processing toolkit based on CommonMark
70
+ email:
71
+ - goro-fuj@bitjoureny.com
72
+ executables: []
73
+ extensions: []
74
+ extra_rdoc_files: []
75
+ files:
76
+ - ".gitignore"
77
+ - ".rubocop.yml"
78
+ - ".travis.yml"
79
+ - Gemfile
80
+ - LICENSE.txt
81
+ - README.md
82
+ - Rakefile
83
+ - bin/console
84
+ - bin/setup
85
+ - example/hello.rb
86
+ - example/toc.rb
87
+ - lib/mato.rb
88
+ - lib/mato/anchor_builder.rb
89
+ - lib/mato/concerns/html_node_checkable.rb
90
+ - lib/mato/config.rb
91
+ - lib/mato/document.rb
92
+ - lib/mato/html_filters/mention_link.rb
93
+ - lib/mato/html_filters/sanitization.rb
94
+ - lib/mato/html_filters/section_anchor.rb
95
+ - lib/mato/html_filters/syntax_highlight.rb
96
+ - lib/mato/html_filters/task_list.rb
97
+ - lib/mato/html_filters/token_link.rb
98
+ - lib/mato/processor.rb
99
+ - lib/mato/renderers/html_renderer.rb
100
+ - lib/mato/renderers/html_toc_renderer.rb
101
+ - lib/mato/version.rb
102
+ - mato.gemspec
103
+ homepage: https://github.com/bitjourney/mato
104
+ licenses:
105
+ - MIT
106
+ metadata: {}
107
+ post_install_message:
108
+ rdoc_options: []
109
+ require_paths:
110
+ - lib
111
+ required_ruby_version: !ruby/object:Gem::Requirement
112
+ requirements:
113
+ - - ">="
114
+ - !ruby/object:Gem::Version
115
+ version: '2.3'
116
+ required_rubygems_version: !ruby/object:Gem::Requirement
117
+ requirements:
118
+ - - ">="
119
+ - !ruby/object:Gem::Version
120
+ version: '0'
121
+ requirements: []
122
+ rubyforge_project:
123
+ rubygems_version: 2.6.11
124
+ signing_key:
125
+ specification_version: 4
126
+ summary: MArkdown TOolkit
127
+ test_files: []