From a25d90335ad3738b6831288190132c2f7498465c Mon Sep 17 00:00:00 2001 From: erdgeist Date: Fri, 10 Jul 2026 00:35:24 +0200 Subject: Retire vendored cacycle_diff.js for a diff-lcs-based diff view Revisions#diff now computes an inline or side-by-side word diff server-side (Page#diff_against, lib/html_word_diff.rb) instead of shipping raw/escaped content to the browser for a 2008-era vendored JS differ to mangle. View mode is picked via a `view` param, settable either on the diff page itself or directly from the revisions#index sticky bar next to "Diff revisions". Also fixes a pre-existing bug in RevisionsController#diff's single- revision branch, which set params[:start]/params[:end] instead of params[:start_revision]/params[:end_revision]. --- lib/html_word_diff.rb | 52 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 lib/html_word_diff.rb (limited to 'lib/html_word_diff.rb') diff --git a/lib/html_word_diff.rb b/lib/html_word_diff.rb new file mode 100644 index 0000000..1f28cf5 --- /dev/null +++ b/lib/html_word_diff.rb @@ -0,0 +1,52 @@ +require 'diff/lcs' + +module HtmlWordDiff + TOKEN_REGEXP = /<[^>]*>|[[:space:]]+|[[:alnum:]]+|[^[:space:][:alnum:]<>]/ + + def self.inline(old_html, new_html) + html = +'' + each_change(old_html, new_html) do |action, old_token, new_token| + case action + when '=' + html << new_token + when '-' + html << "#{old_token}" + when '+' + html << "#{new_token}" + when '!' + html << "#{old_token}#{new_token}" + end + end + html + end + + def self.side_by_side(old_html, new_html) + old_out = +'' + new_out = +'' + each_change(old_html, new_html) do |action, old_token, new_token| + case action + when '=' + old_out << old_token + new_out << new_token + when '-' + old_out << "#{old_token}" + when '+' + new_out << "#{new_token}" + when '!' + old_out << "#{old_token}" + new_out << "#{new_token}" + end + end + [old_out, new_out] + end + + def self.each_change(old_html, new_html) + Diff::LCS.sdiff(tokenize(old_html), tokenize(new_html)).each do |change| + yield change.action, change.old_element, change.new_element + end + end + + def self.tokenize(html) + html.to_s.scan(TOKEN_REGEXP) + end +end -- cgit v1.3 From fd98a2d3053279bd4a848faa52a3ac676db10cbb Mon Sep 17 00:00:00 2001 From: erdgeist Date: Fri, 10 Jul 2026 03:59:34 +0200 Subject: Stop diff output from corrupting the document on structural tag changes sdiff aligned an inserted paragraph break correctly, but the renderer wrapped the raw

/

tokens in regardless -- invalid nesting that browsers silently repair by dropping the unmatched closing tag, leaving everything downstream rendered inside an 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 /. 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. --- lib/html_word_diff.rb | 36 +++++++++++++++++++++++++++++------- test/models/page_test.rb | 18 ++++++++++++++++++ 2 files changed, 47 insertions(+), 7 deletions(-) (limited to 'lib/html_word_diff.rb') 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 @@ require 'diff/lcs' +require 'cgi' module HtmlWordDiff TOKEN_REGEXP = /<[^>]*>|[[:space:]]+|[[:alnum:]]+|[^[:space:][:alnum:]<>]/ @@ -10,11 +11,12 @@ module HtmlWordDiff when '=' html << new_token when '-' - html << "#{old_token}" + html << wrap_removed(old_token) when '+' - html << "#{new_token}" + html << wrap_inserted(new_token) when '!' - html << "#{old_token}#{new_token}" + html << wrap_removed(old_token) + html << wrap_inserted(new_token) end end html @@ -29,12 +31,12 @@ module HtmlWordDiff old_out << old_token new_out << new_token when '-' - old_out << "#{old_token}" + old_out << wrap_removed(old_token) when '+' - new_out << "#{new_token}" + new_out << wrap_inserted(new_token) when '!' - old_out << "#{old_token}" - new_out << "#{new_token}" + old_out << wrap_removed(old_token) + new_out << wrap_inserted(new_token) end end [old_out, new_out] @@ -49,4 +51,24 @@ module HtmlWordDiff def self.tokenize(html) html.to_s.scan(TOKEN_REGEXP) end + + def self.tag_token?(token) + token.to_s.match?(/\A<[^>]*>\z/) + end + + def self.wrap_removed(token) + if tag_token?(token) + %(\u00b6) + else + "#{token}" + end + end + + def self.wrap_inserted(token) + if tag_token?(token) + %(\u00b6) + else + "#{token}" + end + end 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 assert_match "Old", old_html assert_match "New", new_html end + + test "diff_against handles an inserted paragraph split without corrupting the document" do + n = Node.root.children.create! :slug => "paragraph_split_test" + d = n.find_or_create_draft @user1 + d.body = "

Der Vortragsraum ist ab 19 Uhr geöffnet, der Zugang erfolgt über den Hinterhof.

" + d.save! + n.publish_draft! + + d2 = n.find_or_create_draft @user1 + d2.body = "

Der Vortragsraum ist ab 19 Uhr geöffnet,

\n

der Zugang erfolgt über den Hinterhof.

" + d2.save! + + diff = d2.diff_against(n.head) + fragment = Nokogiri::HTML::DocumentFragment.parse(diff[:body]) + + assert_equal 2, fragment.css('ins.diff_structural').length + assert_match "der Zugang erfolgt über den Hinterhof.", fragment.text + end end -- cgit v1.3