diff options
| author | erdgeist <erdgeist@erdgeist.org> | 2026-07-10 03:59:34 +0200 |
|---|---|---|
| committer | erdgeist <erdgeist@erdgeist.org> | 2026-07-10 03:59:34 +0200 |
| commit | fd98a2d3053279bd4a848faa52a3ac676db10cbb (patch) | |
| tree | 8a2aa2a16380d779fe0f8a51dcab917edff636b8 | |
| parent | d18e0fa70de9b5029ebed93a1760aea46e8abf19 (diff) | |
Stop diff output from corrupting the document on structural tag changes
sdiff aligned an inserted paragraph break correctly, but the renderer
wrapped the raw </p>/<p> tokens in <ins> regardless -- invalid nesting
that browsers silently repair by dropping the unmatched closing tag,
leaving everything downstream rendered inside an <ins> with nothing
left to close it. Same failure mode as the retired cacycle_diff.js,
different cause.
Tag tokens now render as a plain-text pilcrow with a tooltip naming
the actual tag, never as literal markup inside <ins>/<del>. Applies to
both inline and side-by-side. Side-by-side's new panel flattening a
real paragraph break into a marker, rather than showing its own true
structure, is a known, accepted trade-off for now -- not the same
problem, and not fixed here.
| -rw-r--r-- | lib/html_word_diff.rb | 36 | ||||
| -rw-r--r-- | test/models/page_test.rb | 18 |
2 files changed, 47 insertions, 7 deletions
diff --git a/lib/html_word_diff.rb b/lib/html_word_diff.rb index 1f28cf5..aa0cb86 100644 --- a/lib/html_word_diff.rb +++ b/lib/html_word_diff.rb | |||
| @@ -1,4 +1,5 @@ | |||
| 1 | require 'diff/lcs' | 1 | require 'diff/lcs' |
| 2 | require 'cgi' | ||
| 2 | 3 | ||
| 3 | module HtmlWordDiff | 4 | module HtmlWordDiff |
| 4 | TOKEN_REGEXP = /<[^>]*>|[[:space:]]+|[[:alnum:]]+|[^[:space:][:alnum:]<>]/ | 5 | TOKEN_REGEXP = /<[^>]*>|[[:space:]]+|[[:alnum:]]+|[^[:space:][:alnum:]<>]/ |
| @@ -10,11 +11,12 @@ module HtmlWordDiff | |||
| 10 | when '=' | 11 | when '=' |
| 11 | html << new_token | 12 | html << new_token |
| 12 | when '-' | 13 | when '-' |
| 13 | html << "<del>#{old_token}</del>" | 14 | html << wrap_removed(old_token) |
| 14 | when '+' | 15 | when '+' |
| 15 | html << "<ins>#{new_token}</ins>" | 16 | html << wrap_inserted(new_token) |
| 16 | when '!' | 17 | when '!' |
| 17 | html << "<del>#{old_token}</del><ins>#{new_token}</ins>" | 18 | html << wrap_removed(old_token) |
| 19 | html << wrap_inserted(new_token) | ||
| 18 | end | 20 | end |
| 19 | end | 21 | end |
| 20 | html | 22 | html |
| @@ -29,12 +31,12 @@ module HtmlWordDiff | |||
| 29 | old_out << old_token | 31 | old_out << old_token |
| 30 | new_out << new_token | 32 | new_out << new_token |
| 31 | when '-' | 33 | when '-' |
| 32 | old_out << "<del>#{old_token}</del>" | 34 | old_out << wrap_removed(old_token) |
| 33 | when '+' | 35 | when '+' |
| 34 | new_out << "<ins>#{new_token}</ins>" | 36 | new_out << wrap_inserted(new_token) |
| 35 | when '!' | 37 | when '!' |
| 36 | old_out << "<del>#{old_token}</del>" | 38 | old_out << wrap_removed(old_token) |
| 37 | new_out << "<ins>#{new_token}</ins>" | 39 | new_out << wrap_inserted(new_token) |
| 38 | end | 40 | end |
| 39 | end | 41 | end |
| 40 | [old_out, new_out] | 42 | [old_out, new_out] |
| @@ -49,4 +51,24 @@ module HtmlWordDiff | |||
| 49 | def self.tokenize(html) | 51 | def self.tokenize(html) |
| 50 | html.to_s.scan(TOKEN_REGEXP) | 52 | html.to_s.scan(TOKEN_REGEXP) |
| 51 | end | 53 | end |
| 54 | |||
| 55 | def self.tag_token?(token) | ||
| 56 | token.to_s.match?(/\A<[^>]*>\z/) | ||
| 57 | end | ||
| 58 | |||
| 59 | def self.wrap_removed(token) | ||
| 60 | if tag_token?(token) | ||
| 61 | %(<del class="diff_structural" title="removed: #{CGI.escapeHTML(token)}">\u00b6</del>) | ||
| 62 | else | ||
| 63 | "<del>#{token}</del>" | ||
| 64 | end | ||
| 65 | end | ||
| 66 | |||
| 67 | def self.wrap_inserted(token) | ||
| 68 | if tag_token?(token) | ||
| 69 | %(<ins class="diff_structural" title="added: #{CGI.escapeHTML(token)}">\u00b6</ins>) | ||
| 70 | else | ||
| 71 | "<ins>#{token}</ins>" | ||
| 72 | end | ||
| 73 | end | ||
| 52 | end | 74 | end |
diff --git a/test/models/page_test.rb b/test/models/page_test.rb index ac5691a..3868a53 100644 --- a/test/models/page_test.rb +++ b/test/models/page_test.rb | |||
| @@ -210,4 +210,22 @@ class PageTest < ActiveSupport::TestCase | |||
| 210 | assert_match "<del>Old</del>", old_html | 210 | assert_match "<del>Old</del>", old_html |
| 211 | assert_match "<ins>New</ins>", new_html | 211 | assert_match "<ins>New</ins>", new_html |
| 212 | end | 212 | end |
| 213 | |||
| 214 | test "diff_against handles an inserted paragraph split without corrupting the document" do | ||
| 215 | n = Node.root.children.create! :slug => "paragraph_split_test" | ||
| 216 | d = n.find_or_create_draft @user1 | ||
| 217 | d.body = "<p>Der Vortragsraum ist ab 19 Uhr geöffnet, der Zugang erfolgt über den Hinterhof.</p>" | ||
| 218 | d.save! | ||
| 219 | n.publish_draft! | ||
| 220 | |||
| 221 | d2 = n.find_or_create_draft @user1 | ||
| 222 | d2.body = "<p>Der Vortragsraum ist ab 19 Uhr geöffnet,</p>\n<p>der Zugang erfolgt über den Hinterhof.</p>" | ||
| 223 | d2.save! | ||
| 224 | |||
| 225 | diff = d2.diff_against(n.head) | ||
| 226 | fragment = Nokogiri::HTML::DocumentFragment.parse(diff[:body]) | ||
| 227 | |||
| 228 | assert_equal 2, fragment.css('ins.diff_structural').length | ||
| 229 | assert_match "der Zugang erfolgt über den Hinterhof.", fragment.text | ||
| 230 | end | ||
| 213 | end | 231 | end |
