├── .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 | [![test](https://github.com/r7kamura/markdiff/actions/workflows/test.yml/badge.svg)](https://github.com/r7kamura/markdiff/actions/workflows/test.yml) 4 | [![Gem Version](https://badge.fury.io/rb/markdiff.svg)](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 #=> '

ab

' 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 | ![demo](images/demo.png) 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 |
82 |
83 |

before

84 | 85 |
86 |
87 |

after

88 | 89 | 90 |
91 |
92 |

diff

93 |
94 | <%= @diff %> 95 |
96 |
97 |
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 '

ab

' 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 '

aaa aaabbb aaa

' 67 | end 68 | end 69 | 70 | context "with adding a new sibling" do 71 | let(:after_string) do 72 | "

a

\n\n

b

\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\n

b

\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 '

aa

' 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 | "
ab
ce
" 143 | end 144 | 145 | let(:before_string) do 146 | "
ab
cd
" 147 | end 148 | 149 | it "returns expected patched node" do 150 | expect(subject.to_html.gsub("\n", "")).to eq '
ab
cde
' 151 | end 152 | end 153 | 154 | context "with ul and li" do 155 | let(:after_string) do 156 | "
  • a
  • b
  • a
" 157 | end 158 | 159 | let(:before_string) do 160 | "
  • a
  • a
  • a
" 161 | end 162 | 163 | it "returns expected patched node" do 164 | expect(subject.to_html.gsub("\n", "")).to eq '
  • a
  • ab
  • a
' 165 | end 166 | end 167 | 168 | context "with removed li" do 169 | let(:after_string) do 170 | "
  • a
" 171 | end 172 | 173 | let(:before_string) do 174 | "
  • a
  • b
" 175 | end 176 | 177 | it "returns expected patched node" do 178 | expect(subject.to_html.gsub("\n", "")).to eq '
  • a
  • b
' 179 | end 180 | end 181 | 182 | context "with added child li" do 183 | let(:after_string) do 184 | "
  • a
  • b
" 185 | end 186 | 187 | let(:before_string) do 188 | "
  • a
" 189 | end 190 | 191 | it "returns expected patched node" do 192 | expect(subject.to_html.gsub("\n", "")).to eq '
  • a
  • b
' 193 | end 194 | end 195 | 196 | context "with removed and added li" do 197 | let(:after_string) do 198 | "
  • c
  • d
" 199 | end 200 | 201 | let(:before_string) do 202 | "
  • a
  • b
" 203 | end 204 | 205 | it "returns expected patched node" do 206 | expect(subject.to_html.gsub("\n", "")).to eq '
  • ac
  • bd
' 207 | end 208 | end 209 | 210 | context "with added sibling li" do 211 | let(:after_string) do 212 | "
  • a
  • b
" 213 | end 214 | 215 | let(:before_string) do 216 | "
  • b
" 217 | end 218 | 219 | it "returns expected patched node" do 220 | expect(subject.to_html.gsub("\n", "")).to eq '
  • a
  • b
' 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 | "a

b

" 259 | end 260 | 261 | it "returns expected patched node" do 262 | expect(subject.to_html).to eq '

c

ad

b

' 263 | end 264 | end 265 | 266 | context "with tasklist" do 267 | let(:after_string) do 268 | '
  • a
' 269 | end 270 | 271 | let(:before_string) do 272 | '
  • a
' 273 | end 274 | 275 | it "returns expected patched node" do 276 | expect(subject.to_html.gsub("\n", "")).to eq '
  • a
' 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\n

a

\n\n

b

\n" 311 | end 312 | 313 | let(:before_string) do 314 | "

a

\n\n

b

\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 | '
  • b
' 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 '
    • b
    ' 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 '

    ab

    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 | --------------------------------------------------------------------------------