├── .github
└── workflows
│ └── test.yml
├── .gitignore
├── CHANGELOG.md
├── Gemfile
├── Gemfile.lock
├── LICENSE.txt
├── README.md
├── Rakefile
├── example
├── app.rb
└── views
│ └── index.erb
├── images
└── demo.png
├── lib
├── markdiff.rb
└── markdiff
│ ├── differ.rb
│ ├── operations
│ ├── add_child_operation.rb
│ ├── add_data_before_href_operation.rb
│ ├── add_data_before_tag_name_operation.rb
│ ├── add_previous_sibling_operation.rb
│ ├── base.rb
│ ├── remove_operation.rb
│ └── text_diff_operation.rb
│ └── version.rb
├── markdiff.gemspec
└── spec
├── markdiff
└── differ_spec.rb
└── spec_helper.rb
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: test
2 |
3 | on:
4 | pull_request:
5 | push:
6 | branches:
7 | - master
8 |
9 | jobs:
10 | build:
11 | runs-on: ubuntu-latest
12 | strategy:
13 | matrix:
14 | ruby:
15 | - 3.0.7
16 | - 3.1.3
17 | steps:
18 | - uses: actions/checkout@v2
19 | - uses: ruby/setup-ruby@v1
20 | with:
21 | ruby-version: ${{ matrix.ruby }}
22 | bundler-cache: true
23 | - run: bundle exec rake
24 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /.bundle/
2 | /.yardoc
3 | /_yardoc/
4 | /coverage/
5 | /doc/
6 | /pkg/
7 | /spec/reports/
8 | /tmp/
9 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ## Unreleased
4 |
5 | ### Removed
6 |
7 | - Drop Ruby 2.7 support.
8 |
9 | ## 0.8.1
10 |
11 | ### Fixed
12 |
13 | - Fix issue with diffs between blocks of matches.
14 |
15 | ## 0.8.0
16 |
17 | ### Changed
18 |
19 | - Add class attributes to ins element: `.ins.ins-before` or `.ins.ins-after`.
20 |
21 | ### Fixed
22 |
23 | - Fix issue on insertion and deletion on same position.
24 | - Fix issue with long sentences.
25 |
26 | ## 0.7.0
27 |
28 | ### Fixed
29 |
30 | - Fixed replaces shown wrongly.
31 |
32 | ## 0.6.3
33 |
34 | ### Fixed
35 |
36 | - Support diff-lcs v1.4+.
37 |
38 | ## 0.6.2
39 |
40 | ### Changed
41 |
42 | - Support tr.added and tr.deleted.
43 |
44 | ## 0.6.1
45 |
46 | ### Changed
47 |
48 | - Fix patch order bug by making sort stable.
49 |
50 | ## 0.6.0
51 |
52 | ### Changed
53 |
54 | - Add .del class to all del elements.
55 |
56 | ## 0.5.5
57 |
58 | ### Fixed
59 |
60 | - Preserve classes on adding new class (e.g. .added).
61 |
62 | ## 0.5.4
63 |
64 | ### Fixed
65 |
66 | - Fix bug on patch operations order.
67 |
68 | ## 0.5.3
69 |
70 | ### Fixed
71 |
72 | - Fix bug on comparing nodes.
73 |
74 | ## 0.5.2
75 |
76 | ### Fixed
77 |
78 | - Fix bug on comparing attributes and text nodes.
79 |
80 | ## 0.5.1
81 |
82 | ### Fixed
83 |
84 | - Fix bug on text-diff operation.
85 |
86 | ## 0.5.0
87 |
88 | ### Changed
89 |
90 | - Wrap changed nodes by div.changed.
91 |
92 | ## 0.4.0
93 |
94 | ### Changed
95 |
96 | - Chanage .changed specs.
97 |
98 | ### Fixed
99 |
100 | - Fix bugs on text diff.
101 |
102 | ## 0.3.0
103 |
104 | ### Changed
105 |
106 | - Support partial text diff.
107 |
108 | ## 0.2.1
109 |
110 | ### Changed
111 |
112 | - Support li.added and li.removed.
113 |
114 | ## 0.2.0
115 |
116 | ### Changed
117 |
118 | - Support div.changed and li.changed.
119 |
120 | ## 0.1.0
121 |
122 | ### Added
123 |
124 | - 1st Release.
125 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | source 'https://rubygems.org'
2 |
3 | # Specify your gem's dependencies in markdiff.gemspec
4 | gemspec
5 |
--------------------------------------------------------------------------------
/Gemfile.lock:
--------------------------------------------------------------------------------
1 | PATH
2 | remote: .
3 | specs:
4 | markdiff (0.8.1)
5 | diff-lcs
6 | nokogiri
7 |
8 | GEM
9 | remote: https://rubygems.org/
10 | specs:
11 | diff-lcs (1.6.1)
12 | nokogiri (1.17.2-x86_64-linux)
13 | racc (~> 1.4)
14 | racc (1.8.1)
15 | rake (13.2.1)
16 | redcarpet (3.6.1)
17 | rspec (3.13.0)
18 | rspec-core (~> 3.13.0)
19 | rspec-expectations (~> 3.13.0)
20 | rspec-mocks (~> 3.13.0)
21 | rspec-core (3.13.3)
22 | rspec-support (~> 3.13.0)
23 | rspec-expectations (3.13.4)
24 | diff-lcs (>= 1.2.0, < 2.0)
25 | rspec-support (~> 3.13.0)
26 | rspec-mocks (3.13.3)
27 | diff-lcs (>= 1.2.0, < 2.0)
28 | rspec-support (~> 3.13.0)
29 | rspec-support (3.13.3)
30 |
31 | PLATFORMS
32 | x86_64-linux
33 |
34 | DEPENDENCIES
35 | bundler
36 | markdiff!
37 | rake
38 | redcarpet
39 | rspec
40 |
41 | BUNDLED WITH
42 | 2.3.6
43 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2015 r7kamura
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.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Markdiff
2 |
3 | [](https://github.com/r7kamura/markdiff/actions/workflows/test.yml)
4 | [](https://rubygems.org/gems/markdiff)
5 |
6 | Rendered Markdown differ.
7 |
8 | ## Usage
9 | ```rb
10 | require "markdiff"
11 |
12 | differ = Markdiff::Differ.new
13 | node = differ.render("
a
", "b
")
14 | node.to_html #=> ''
15 | ```
16 |
17 | See [spec/markdiff/differ_spec.rb](spec/markdiff/differ_spec.rb) for more examples.
18 |
19 | ### Demo
20 | Execute `ruby example/app.rb` to run [demo app](example/app.rb).
21 |
22 | 
23 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | require "bundler/gem_tasks"
2 | require "rspec/core/rake_task"
3 |
4 | RSpec::Core::RakeTask.new(:spec)
5 | task default: :spec
6 |
--------------------------------------------------------------------------------
/example/app.rb:
--------------------------------------------------------------------------------
1 | require "markdiff"
2 | require "redcarpet"
3 | require "sinatra"
4 |
5 | markdown = Redcarpet::Markdown.new(Redcarpet::Render::HTML, autolink: true, tables: true)
6 |
7 | get "/" do
8 | if params[:before] && params[:after]
9 | html_before = markdown.render(params[:before])
10 | html_after = markdown.render(params[:after])
11 | p html_before
12 | p html_after
13 | @diff = Markdiff::Differ.new.render(html_before, html_after)
14 | end
15 | erb :index
16 | end
17 |
--------------------------------------------------------------------------------
/example/views/index.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Markdiff
6 |
79 |
80 |
81 |
98 |
99 |
100 |
--------------------------------------------------------------------------------
/images/demo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/r7kamura/markdiff/7e95c63fd21a3cc6708ce2f89ceef007ca7b6468/images/demo.png
--------------------------------------------------------------------------------
/lib/markdiff.rb:
--------------------------------------------------------------------------------
1 | require "markdiff/differ"
2 | require "markdiff/version"
3 |
--------------------------------------------------------------------------------
/lib/markdiff/differ.rb:
--------------------------------------------------------------------------------
1 | require "diff/lcs"
2 | require "nokogiri"
3 | require "markdiff/operations/add_child_operation"
4 | require "markdiff/operations/add_data_before_href_operation"
5 | require "markdiff/operations/add_data_before_tag_name_operation"
6 | require "markdiff/operations/add_previous_sibling_operation"
7 | require "markdiff/operations/remove_operation"
8 | require "markdiff/operations/text_diff_operation"
9 |
10 | module Markdiff
11 | class Differ
12 | # Apply a given patch to a given node
13 | # @param [Array] operations
14 | # @param [Nokogiri::XML::Node] node
15 | # @return [Nokogiri::XML::Node] Converted node
16 | def apply_patch(operations, node)
17 | i = 0
18 | operations.sort_by {|operation| i += 1; [-operation.priority, i] }.each do |operation|
19 | case operation
20 | when ::Markdiff::Operations::AddChildOperation
21 | operation.target_node.add_child(operation.inserted_node)
22 | mark_li_or_tr_as_changed(operation.target_node)
23 | mark_top_level_node_as_changed(operation.target_node)
24 | when ::Markdiff::Operations::AddDataBeforeHrefOperation
25 | operation.target_node["data-before-href"] = operation.target_node["href"]
26 | operation.target_node["href"] = operation.after_href
27 | mark_li_or_tr_as_changed(operation.target_node)
28 | mark_top_level_node_as_changed(operation.target_node)
29 | when ::Markdiff::Operations::AddDataBeforeTagNameOperation
30 | operation.target_node["data-before-tag-name"] = operation.target_node.name
31 | operation.target_node.name = operation.after_tag_name
32 | mark_li_or_tr_as_changed(operation.target_node)
33 | mark_top_level_node_as_changed(operation.target_node)
34 | when ::Markdiff::Operations::AddPreviousSiblingOperation
35 | operation.target_node.add_previous_sibling(operation.inserted_node)
36 | mark_li_or_tr_as_changed(operation.target_node) if operation.target_node.name != "li" && operation.target_node.name != "tr"
37 | mark_top_level_node_as_changed(operation.target_node.parent)
38 | when ::Markdiff::Operations::RemoveOperation
39 | operation.target_node.replace(operation.inserted_node) if operation.target_node != operation.inserted_node
40 | mark_li_or_tr_as_changed(operation.target_node)
41 | mark_top_level_node_as_changed(operation.target_node)
42 | when ::Markdiff::Operations::TextDiffOperation
43 | parent = operation.target_node.parent
44 | operation.target_node.replace(operation.inserted_node)
45 | mark_li_or_tr_as_changed(parent)
46 | mark_top_level_node_as_changed(parent)
47 | end
48 | end
49 | node
50 | end
51 |
52 | # Creates a patch from given two nodes
53 | # @param [Nokogiri::XML::Node] before_node
54 | # @param [Nokogiri::XML::Node] after_node
55 | # @return [Array] operations
56 | def create_patch(before_node, after_node)
57 | if before_node.to_html == after_node.to_html
58 | []
59 | else
60 | create_patch_from_children(before_node, after_node)
61 | end
62 | end
63 |
64 | # Utility method to do both creating and applying a patch
65 | # @param [String] before_string
66 | # @param [String] after_string
67 | # @return [Nokogiri::XML::Node] Converted node
68 | def render(before_string, after_string)
69 | before_node = ::Nokogiri::HTML.fragment(before_string)
70 | after_node = ::Nokogiri::HTML.fragment(after_string)
71 | patch = create_patch(before_node, after_node)
72 | apply_patch(patch, before_node)
73 | end
74 |
75 | private
76 |
77 | # 1. Create identity map and collect patches from descendants
78 | # 1-1. Detect exact-matched nodes
79 | # 1-2. Detect partial-matched nodes and recursively walk through its children
80 | # 2. Create remove operations from identity map
81 | # 3. Create insert operations from identity map
82 | # 4. Return operations as a patch
83 | #
84 | # @param [Nokogiri::XML::Node] before_node
85 | # @param [Nokogiri::XML::Node] after_node
86 | # @return [Array] operations
87 | def create_patch_from_children(before_node, after_node)
88 | operations = []
89 | identity_map = {}
90 | inverted_identity_map = {}
91 |
92 | ::Diff::LCS.sdiff(before_node.children.map(&:to_s), after_node.children.map(&:to_s)).each do |element|
93 | type, before, after = *element
94 | if type == "="
95 | before_child = before_node.children[before[0]]
96 | after_child = after_node.children[after[0]]
97 | identity_map[before_child] = after_child
98 | inverted_identity_map[after_child] = before_child
99 | end
100 | end
101 |
102 | # Partial matching
103 | before_node.children.each_with_index do |before_child, index|
104 | next if identity_map[before_child]
105 |
106 | next_match = before_node.children[index..].find { |child| identity_map[child] }
107 |
108 | after_node.children.each do |after_child|
109 | case
110 | when identity_map[before_child]
111 | break
112 | when next_match && inverted_identity_map[after_child] == next_match
113 | break
114 | when inverted_identity_map[after_child]
115 | when before_child.text?
116 | if after_child.text?
117 | identity_map[before_child] = after_child
118 | inverted_identity_map[after_child] = before_child
119 | operations << ::Markdiff::Operations::TextDiffOperation.new(target_node: before_child, after_node: after_child)
120 | end
121 | when before_child.name == after_child.name
122 | if before_child.attributes == after_child.attributes
123 | identity_map[before_child] = after_child
124 | inverted_identity_map[after_child] = before_child
125 | operations += create_patch(before_child, after_child)
126 | elsif detect_href_difference(before_child, after_child)
127 | operations << ::Markdiff::Operations::AddDataBeforeHrefOperation.new(after_href: after_child["href"], target_node: before_child)
128 | identity_map[before_child] = after_child
129 | inverted_identity_map[after_child] = before_child
130 | operations += create_patch(before_child, after_child)
131 | end
132 | when detect_heading_level_difference(before_child, after_child)
133 | operations << ::Markdiff::Operations::AddDataBeforeTagNameOperation.new(after_tag_name: after_child.name, target_node: before_child)
134 | identity_map[before_child] = after_child
135 | inverted_identity_map[after_child] = before_child
136 | end
137 | end
138 | end
139 |
140 | before_node.children.each do |before_child|
141 | unless identity_map[before_child]
142 | operations << ::Markdiff::Operations::RemoveOperation.new(target_node: before_child)
143 | end
144 | end
145 |
146 | after_node.children.each do |after_child|
147 | unless inverted_identity_map[after_child]
148 | right_node = after_child.next_sibling
149 | loop do
150 | case
151 | when inverted_identity_map[right_node]
152 | operations << ::Markdiff::Operations::AddPreviousSiblingOperation.new(inserted_node: after_child, target_node: inverted_identity_map[right_node])
153 | break
154 | when right_node.nil?
155 | operations << ::Markdiff::Operations::AddChildOperation.new(inserted_node: after_child, target_node: before_node)
156 | break
157 | else
158 | right_node = right_node.next_sibling
159 | end
160 | end
161 | end
162 | end
163 |
164 | operations
165 | end
166 |
167 | # @param [Nokogiri::XML::Node] before_node
168 | # @param [Nokogiri::XML::Node] after_node
169 | # @return [false, true] True if given 2 nodes are both hN nodes and have different N (e.g. h1 and h2)
170 | def detect_heading_level_difference(before_node, after_node)
171 | before_node.name != after_node.name &&
172 | %w[h1 h2 h3 h4 h5 h6].include?(before_node.name) &&
173 | %w[h1 h2 h3 h4 h5 h6].include?(after_node.name) &&
174 | before_node.inner_html == after_node.inner_html
175 | end
176 |
177 | # @param [Nokogiri::XML::Node] before_node
178 | # @param [Nokogiri::XML::Node] after_node
179 | # @return [false, true] True if given 2 nodes are both "a" nodes and have different href attributes
180 | def detect_href_difference(before_node, after_node)
181 | before_node.name == "a" &&
182 | after_node.name == "a" &&
183 | before_node["href"] != after_node["href"] &&
184 | before_node.inner_html == after_node.inner_html
185 | end
186 |
187 | # @param [Nokogiri::XML::Node] node
188 | def mark_li_or_tr_as_changed(node)
189 | until node.parent.nil? || node.parent.fragment?
190 | if node.name == "li" || node.name == "tr"
191 | classes = node["class"].to_s.split(/\s/)
192 | unless classes.include?("added") || classes.include?("changed") || classes.include?("removed")
193 | node["class"] = (classes + ["changed"]).join(" ")
194 | end
195 | end
196 | node = node.parent
197 | end
198 | end
199 |
200 | # @param [Nokogiri::XML::Node] node
201 | def mark_top_level_node_as_changed(node)
202 | return if node.nil?
203 | node = node.parent until node.parent.nil? || node.parent.fragment?
204 | unless node.parent.nil? || node["class"] == "changed"
205 | div = Nokogiri::XML::Node.new("div", node.document)
206 | div["class"] = "changed"
207 | node.replace(div)
208 | div.children = node
209 | end
210 | end
211 | end
212 | end
213 |
--------------------------------------------------------------------------------
/lib/markdiff/operations/add_child_operation.rb:
--------------------------------------------------------------------------------
1 | require "markdiff/operations/base"
2 |
3 | module Markdiff
4 | module Operations
5 | class AddChildOperation < Base
6 | # @return [String]
7 | def inserted_node
8 | if @inserted_node.name == "li" || @inserted_node.name == "tr"
9 | @inserted_node["class"] = (@inserted_node["class"].to_s.split(/\s/) + ["added"]).join(" ")
10 | @inserted_node.inner_html = "#{@inserted_node.inner_html}"
11 | @inserted_node
12 | else
13 | "#{@inserted_node}"
14 | end
15 | end
16 | end
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/lib/markdiff/operations/add_data_before_href_operation.rb:
--------------------------------------------------------------------------------
1 | require "markdiff/operations/base"
2 |
3 | module Markdiff
4 | module Operations
5 | class AddDataBeforeHrefOperation < Base
6 | attr_reader :after_href
7 |
8 | def initialize(after_href:, **args)
9 | super(**args)
10 | @after_href = after_href
11 | end
12 | end
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/lib/markdiff/operations/add_data_before_tag_name_operation.rb:
--------------------------------------------------------------------------------
1 | require "markdiff/operations/base"
2 |
3 | module Markdiff
4 | module Operations
5 | class AddDataBeforeTagNameOperation < Base
6 | attr_reader :after_tag_name
7 |
8 | def initialize(after_tag_name:, **args)
9 | super(**args)
10 | @after_tag_name = after_tag_name
11 | end
12 | end
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/lib/markdiff/operations/add_previous_sibling_operation.rb:
--------------------------------------------------------------------------------
1 | require "markdiff/operations/base"
2 |
3 | module Markdiff
4 | module Operations
5 | class AddPreviousSiblingOperation < Base
6 | # @return [String]
7 | def inserted_node
8 | if @inserted_node.name == "li" || @inserted_node.name == "tr"
9 | node = @inserted_node.clone
10 | node["class"] = (node["class"].to_s.split(/\s/) + ["added"]).join(" ")
11 | node.inner_html = "#{@inserted_node.inner_html}"
12 | node
13 | else
14 | "#{@inserted_node}"
15 | end
16 | end
17 | end
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/lib/markdiff/operations/base.rb:
--------------------------------------------------------------------------------
1 | module Markdiff
2 | module Operations
3 | class Base
4 | # @return [Nokogiri::XML::Node]
5 | attr_reader :target_node
6 |
7 | # @param [Nokogiri::XML::Node, nil] inserted_node
8 | # @param [Nokogiri::XML::Node] target_node
9 | def initialize(inserted_node: nil, target_node:)
10 | @inserted_node = inserted_node
11 | @target_node = target_node
12 | end
13 |
14 | def priority
15 | 3
16 | end
17 | end
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/lib/markdiff/operations/remove_operation.rb:
--------------------------------------------------------------------------------
1 | require "markdiff/operations/base"
2 |
3 | module Markdiff
4 | module Operations
5 | class RemoveOperation < Base
6 | # @return [String]
7 | def inserted_node
8 | if target_node.name == "li" || target_node.name == "tr"
9 | target_node["class"] = "removed"
10 | target_node.inner_html = %(#{target_node.inner_html})
11 | target_node
12 | else
13 | %(#{target_node})
14 | end
15 | end
16 |
17 | def priority
18 | 2
19 | end
20 | end
21 | end
22 | end
23 |
--------------------------------------------------------------------------------
/lib/markdiff/operations/text_diff_operation.rb:
--------------------------------------------------------------------------------
1 | require "diff/lcs"
2 | require "nokogiri"
3 | require "markdiff/operations/base"
4 |
5 | module Markdiff
6 | module Operations
7 | class TextDiffOperation < Base
8 | # @param [Nokogiri::XML::Node] after_node
9 | def initialize(after_node:, **args)
10 | super(**args)
11 | @after_node = after_node
12 | end
13 |
14 | # @return [Nokogiri::XML::Node]
15 | def inserted_node
16 | before_elements = target_node.to_s.split(' ')
17 | after_elements = @after_node.to_s.split(' ')
18 | last_operation = nil
19 |
20 | groupings = ::Diff::LCS.sdiff(before_elements, after_elements)
21 | .slice_when { |prev, cur| prev.action != cur.action }
22 |
23 | output = groupings.map do |grouping|
24 | action = grouping.first.action
25 |
26 | response = case action
27 | when "="
28 | grouping.map(&:new_element).join(" ")
29 | when "-"
30 | %(#{grouping.map(&:old_element).join(" ")})
31 | when "+"
32 | %(#{grouping.map(&:new_element).join(" ")})
33 | when "!"
34 | %(#{grouping.map(&:old_element).join(" ")}#{grouping.map(&:new_element).join(" ")})
35 | else
36 | raise "Unknown action #{action}"
37 | end
38 |
39 | response = " #{response}" if last_operation && last_operation != '+'
40 |
41 | last_operation = action
42 |
43 | response
44 | end
45 |
46 | ::Nokogiri::HTML.fragment(output.join(''))
47 | end
48 |
49 | def priority
50 | 1
51 | end
52 | end
53 | end
54 | end
55 |
--------------------------------------------------------------------------------
/lib/markdiff/version.rb:
--------------------------------------------------------------------------------
1 | module Markdiff
2 | VERSION = "0.8.1"
3 | end
4 |
--------------------------------------------------------------------------------
/markdiff.gemspec:
--------------------------------------------------------------------------------
1 | lib = File.expand_path("../lib", __FILE__)
2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3 | require "markdiff/version"
4 |
5 | Gem::Specification.new do |spec|
6 | spec.name = "markdiff"
7 | spec.version = Markdiff::VERSION
8 | spec.authors = ["Ryo Nakamura"]
9 | spec.email = ["r7kamura@gmail.com"]
10 | spec.summary = "Rendered Markdown differ."
11 | spec.homepage = "https://github.com/r7kamura/markdiff"
12 | spec.license = "MIT"
13 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.start_with?("spec/") }
14 | spec.require_paths = ["lib"]
15 |
16 | spec.required_ruby_version = ">= 3"
17 |
18 | spec.add_development_dependency "bundler"
19 | spec.add_development_dependency "rake"
20 | spec.add_development_dependency "redcarpet"
21 | spec.add_development_dependency "rspec"
22 | spec.add_runtime_dependency "diff-lcs"
23 | spec.add_runtime_dependency "nokogiri"
24 | end
25 |
--------------------------------------------------------------------------------
/spec/markdiff/differ_spec.rb:
--------------------------------------------------------------------------------
1 | require "markdiff"
2 | require "spec_helper"
3 |
4 | RSpec.describe Markdiff::Differ do
5 | let(:differ) do
6 | described_class.new
7 | end
8 |
9 | describe "#render" do
10 | subject do
11 | differ.render(before_string, after_string)
12 | end
13 |
14 | context "with any valid arguments" do
15 | let(:after_string) do
16 | "b
"
17 | end
18 |
19 | let(:before_string) do
20 | "a
"
21 | end
22 |
23 | it "returns a Nokogiri::XML::Node" do
24 | expect(subject).to be_a Nokogiri::XML::Node
25 | end
26 | end
27 |
28 | context "with same node" do
29 | let(:after_string) do
30 | before_string
31 | end
32 |
33 | let(:before_string) do
34 | "a
"
35 | end
36 |
37 | it "returns same node" do
38 | expect(subject.to_html).to eq before_string
39 | end
40 | end
41 |
42 | context "with different text node" do
43 | let(:after_string) do
44 | "b
"
45 | end
46 |
47 | let(:before_string) do
48 | "a
"
49 | end
50 |
51 | it "returns expected patched node" do
52 | expect(subject.to_html).to eq ''
53 | end
54 | end
55 |
56 | context "with partial difference in text node" do
57 | let(:after_string) do
58 | "aaa bbb aaa
"
59 | end
60 |
61 | let(:before_string) do
62 | "aaa aaa aaa
"
63 | end
64 |
65 | it "returns expected patched node" do
66 | expect(subject.to_html).to eq ''
67 | end
68 | end
69 |
70 | context "with adding a new sibling" do
71 | let(:after_string) do
72 | "a
\n\nb
\n"
73 | end
74 |
75 | let(:before_string) do
76 | "b
\n"
77 | end
78 |
79 | it "returns expected patched node" do
80 | expect(subject.to_html).to eq "a
\n\nb
\n"
81 | end
82 | end
83 |
84 | context "with different tag name" do
85 | let(:after_string) do
86 | "a
"
87 | end
88 |
89 | let(:before_string) do
90 | "a
"
91 | end
92 |
93 | it "returns expected patched node" do
94 | expect(subject.to_html).to eq 'a
a
'
95 | end
96 | end
97 |
98 | context "with difference in nested node" do
99 | let(:after_string) do
100 | "a
"
101 | end
102 |
103 | let(:before_string) do
104 | "a
"
105 | end
106 |
107 | it "returns expected patched node" do
108 | expect(subject.to_html).to eq ''
109 | end
110 | end
111 |
112 | context "with difference in sibling" do
113 | let(:after_string) do
114 | "a
b
"
115 | end
116 |
117 | let(:before_string) do
118 | "b
"
119 | end
120 |
121 | it "returns expected patched node" do
122 | expect(subject.to_html).to eq "a
b
"
123 | end
124 | end
125 |
126 | context "with removing" do
127 | let(:after_string) do
128 | "a
"
129 | end
130 |
131 | let(:before_string) do
132 | "a
b
"
133 | end
134 |
135 | it "returns expected patched node" do
136 | expect(subject.to_html).to eq 'a
b
'
137 | end
138 | end
139 |
140 | context "with difference in table tag" do
141 | let(:after_string) do
142 | ""
143 | end
144 |
145 | let(:before_string) do
146 | ""
147 | end
148 |
149 | it "returns expected patched node" do
150 | expect(subject.to_html.gsub("\n", "")).to eq ''
151 | end
152 | end
153 |
154 | context "with ul and li" do
155 | let(:after_string) do
156 | ""
157 | end
158 |
159 | let(:before_string) do
160 | ""
161 | end
162 |
163 | it "returns expected patched node" do
164 | expect(subject.to_html.gsub("\n", "")).to eq ''
165 | end
166 | end
167 |
168 | context "with removed li" do
169 | let(:after_string) do
170 | ""
171 | end
172 |
173 | let(:before_string) do
174 | ""
175 | end
176 |
177 | it "returns expected patched node" do
178 | expect(subject.to_html.gsub("\n", "")).to eq ''
179 | end
180 | end
181 |
182 | context "with added child li" do
183 | let(:after_string) do
184 | ""
185 | end
186 |
187 | let(:before_string) do
188 | ""
189 | end
190 |
191 | it "returns expected patched node" do
192 | expect(subject.to_html.gsub("\n", "")).to eq ''
193 | end
194 | end
195 |
196 | context "with removed and added li" do
197 | let(:after_string) do
198 | ""
199 | end
200 |
201 | let(:before_string) do
202 | ""
203 | end
204 |
205 | it "returns expected patched node" do
206 | expect(subject.to_html.gsub("\n", "")).to eq ''
207 | end
208 | end
209 |
210 | context "with added sibling li" do
211 | let(:after_string) do
212 | ""
213 | end
214 |
215 | let(:before_string) do
216 | ""
217 | end
218 |
219 | it "returns expected patched node" do
220 | expect(subject.to_html.gsub("\n", "")).to eq ''
221 | end
222 | end
223 |
224 | context "with different href in a node" do
225 | let(:after_string) do
226 | 'a
'
227 | end
228 |
229 | let(:before_string) do
230 | 'a
'
231 | end
232 |
233 | it "returns expected patched node" do
234 | expect(subject.to_html).to eq ''
235 | end
236 | end
237 |
238 | context "with different level heading nodes" do
239 | let(:after_string) do
240 | "a
"
241 | end
242 |
243 | let(:before_string) do
244 | "a
"
245 | end
246 |
247 | it "returns expected patched node" do
248 | expect(subject.to_html).to eq 'a
'
249 | end
250 | end
251 |
252 | context "with replaced operation target node" do
253 | let(:after_string) do
254 | "c
d"
255 | end
256 |
257 | let(:before_string) do
258 | "ab
"
259 | end
260 |
261 | it "returns expected patched node" do
262 | expect(subject.to_html).to eq 'c
adb
'
263 | end
264 | end
265 |
266 | context "with tasklist" do
267 | let(:after_string) do
268 | ''
269 | end
270 |
271 | let(:before_string) do
272 | ''
273 | end
274 |
275 | it "returns expected patched node" do
276 | expect(subject.to_html.gsub("\n", "")).to eq ''
277 | end
278 | end
279 |
280 | context "with inserted word in the middle of the text" do
281 | let(:after_string) do
282 | "Kurset skal give de studerende TEST procesforståelse samt teoretisk og praktisk erfaring.
"
283 | end
284 | let(:before_string) do
285 | "Kurset skal give de studerende procesforståelse samt teoretisk og praktisk erfaring.
"
286 | end
287 |
288 | it "returns the expected patched text" do
289 | expect(subject.to_html)
290 | .to eq 'Kurset skal give de studerende TESTprocesforståelse samt teoretisk og praktisk erfaring.
'
291 | end
292 | end
293 |
294 | context "with added word at the beginning" do
295 | let(:after_string) do
296 | "Det Kurset skal give de studerende procesforståelse samt teoretisk og praktisk erfaring."
297 | end
298 | let(:before_string) do
299 | "Kurset skal give de studerende procesforståelse samt teoretisk og praktisk erfaring."
300 | end
301 |
302 | it "returns the expected patched node" do
303 | expect(subject.to_html)
304 | .to eq 'DetKurset skal give de studerende procesforståelse samt teoretisk og praktisk erfaring.'
305 | end
306 | end
307 |
308 | context "with prepending node" do
309 | let(:after_string) do
310 | "added
\n\na
\n\nb
\n"
311 | end
312 |
313 | let(:before_string) do
314 | "a
\n\nb
\n"
315 | end
316 |
317 | it "returns expected patched node" do
318 | expect(subject.to_html.gsub("\n", "")).to eq "added
a
b
"
319 | end
320 | end
321 |
322 | context "with classed li node" do
323 | let(:after_string) do
324 | ''
325 | end
326 |
327 | let(:before_string) do
328 | ''
329 | end
330 |
331 | it "returns expected patched node" do
332 | expect(subject.to_html.gsub("\n", "")).to eq ''
333 | end
334 | end
335 |
336 | context "with a sequence of AddChild operations" do
337 | let(:after_string) do
338 | "b
c
d
"
339 | end
340 |
341 | let(:before_string) do
342 | "a
"
343 | end
344 |
345 | it "returns expected patched node" do
346 | expect(subject.to_html).to eq 'c
d
'
347 | end
348 | end
349 |
350 | context "with insertion and deletion on same positions" do
351 | let(:after_string) { "JEG HEDDER Kurset give de studerende procesforståelse" }
352 | let(:before_string) { "Kurset skal give de studerende procesforståelse" }
353 |
354 | it 'returns expected patched node' do
355 | expect(subject.to_html).to eq 'JEG HEDDERKurset skal give de studerende procesforståelse'
356 | end
357 | end
358 |
359 | context "with lots of changes" do
360 | let(:after_string) do
361 | "Der gælder for specialer udført ved Faculty of Natural Sciences og Faculty of Technical Sciences, Et Universitet. Hovedvejleder har det formelle ansvar for den faglige vejledning."
362 | end
363 | let(:before_string) do
364 | "Der gælder for specialer udført ved Science & Technology, Et Universitet. Hovedvejleder har det formelle ansvar for den faglige vejledning."
365 | end
366 |
367 | it "returns the expected patched note" do
368 | expect(subject.to_html)
369 | .to eq 'Der gælder for specialer udført ved Science & Technology,Faculty of Natural Sciences og Faculty of Technical Sciences,Et Universitet. Hovedvejleder har det formelle ansvar for den faglige vejledning.'
370 | end
371 | end
372 |
373 | context "with a long sentence" do
374 | let(:after_string) do
375 | "De matematiske begreber kommer først og fremmest i kurset vil blive underbygget af små eksperimenter i programmeringssprogene php, Sage og ruby."
376 | end
377 | let(:before_string) do
378 | "De matematiske asd begreber i kurset vil blive underbygget af små eksperimenter i programmeringssprogene Sage og python."
379 | end
380 |
381 | it "returns the expected patched note" do
382 | expect(subject.to_html)
383 | .to eq 'De matematiske asd begreber kommer først og fremmesti kurset vil blive underbygget af små eksperimenter i programmeringssprogene php,Sage og python.ruby.'
384 | end
385 | end
386 |
387 | context 'with list items' do
388 | let(:after_string) do
389 | '- Tilstandsligninger for gasser, herunder ikke-idealitet
- Termodynamiske nøglebegreber (herunder enthalpi, entropi, Gibbs energi, kemisk potentiale, varmekapacitet)
- Faseligevægte og fasediagrammer
- Opløselighed og kolligative egenskaber
- Kemisk ligevægt, herunder aktivitetsbegrebet og sammenhæng med Gibbs energi
- Elektrokemiske nøglebegreber (herunder standardpotentialer, Nernst-ligningen)
- Kinetik (herunder reaktionsorden, temperaturafhængighed og Arrhenius-ligningen, elementarreaktioner, katalyse)
- Systematisk opstilling af flowdiagram (herunder frihedsgradsanalyse og opstilling af stofbalancer)
- Der arbejdes systematisk med forskellige beregninger på kemitekniske processer, herunder beregninger af manglende data ud fra de opstillede stofbalancer samt energibalancer for både ikke-reaktive og reaktive systemer
'
390 | end
391 | let(:before_string) do
392 | '- Tilstandsligninger for gasser, herunder ikke-idealitet
- Kinetisk gasteori
- Termodynamiske nøglebegreber (herunder enthalpi, entropi, Gibbs energi, kemisk potentiale, varmekapacitet)
- Faseligevægte og fasediagrammer
- Opløselighed og kolligative egenskaber
- Kemisk ligevægt, herunder aktivitetsbegrebet og sammenhæng med Gibbs energi
- Elektrokemiske nøglebegreber (herunder standardpotentialer, Nernst-ligningen)
- Kinetik (herunder reaktionsorden, temperaturafhængighed og Arrhenius-ligningen, elementarreaktioner)
- Katalyse
- Systematisk opstilling af flowdiagram (herunder frihedsgradsanalyse og opstilling af stofbalancer)
- Der arbejdes systematisk med forskellige beregninger på kemitekniske processer, herunder beregninger af manglende data fra de opstillede stofbalancer samt energibalancer for både ikke-reaktive og reaktive systemer
'
393 | end
394 |
395 | it 'diffs a long list' do
396 | expect(subject.to_html)
397 | .to eq <<~STRING.strip
398 |
399 | - Tilstandsligninger for gasser, herunder ikke-idealitet
400 | Kinetisk gasteori
401 | - Termodynamiske nøglebegreber (herunder enthalpi, entropi, Gibbs energi, kemisk potentiale, varmekapacitet)
402 | - Faseligevægte og fasediagrammer
403 | - Opløselighed og kolligative egenskaber
404 | - Kemisk ligevægt, herunder aktivitetsbegrebet og sammenhæng med Gibbs energi
405 | - Elektrokemiske nøglebegreber (herunder standardpotentialer, Nernst-ligningen)
406 | - Kinetik (herunder reaktionsorden, temperaturafhængighed og Arrhenius-ligningen,
elementarreaktioner)elementarreaktioner, katalyse)
407 |
408 | Katalyse
409 | - Systematisk opstilling af flowdiagram (herunder frihedsgradsanalyse og opstilling af stofbalancer)
410 | - Der arbejdes systematisk med forskellige beregninger på kemitekniske processer, herunder beregninger af manglende data udfra de opstillede stofbalancer samt energibalancer for både ikke-reaktive og reaktive systemer
411 |
412 | STRING
413 | end
414 | end
415 | end
416 | end
417 |
--------------------------------------------------------------------------------
/spec/spec_helper.rb:
--------------------------------------------------------------------------------
1 | RSpec.configure do |config|
2 | config.filter_run :focus
3 | config.disable_monkey_patching!
4 | config.run_all_when_everything_filtered = true
5 | end
6 |
--------------------------------------------------------------------------------