1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
|
<!DOCTYPE html>
<html><head>
<meta charset="utf-8">
<title>wsdiff: Responsive diffs in plain HTML | Home</title>
<meta name="description" content="">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="mobile-web-app-capable" content="yes">
<meta name="color-scheme" content="dark light">
<link rel="stylesheet" href="/style.css">
<link rel="preload" href="/fonts/roboto_slab/RobotoSlab-VariableFont_wght.ttf" as="font" type="font/ttf" crossorigin />
<link rel="preload" href="/fonts/nyght-serif-main/fonts/WEB/NyghtSerif-Regular.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/fonts/nyght-serif-main/fonts/WEB/NyghtSerif-Bold.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/fonts/nyght-serif-main/fonts/WEB/NyghtSerif-BoldItalic.woff2" as="font" type="font/woff2" crossorigin />
</head>
<body><nav>
<div class="internal">
<a href="/" title="Home">Home</a>
<a href="/blog/" title="Blog">Blog</a>
<a href="/projects/" title="Projects">Projects</a>
<a href="/about/" title="About">About</a>
</div>
<div class="search">
<div id="search"></div>
</div>
<div class="external">
<a href="https://git.jaseg.de/" title="cgit">cgit</a>
<a href="https://github.com/jaseg" title="Github">Github</a>
<a href="https://gitlab.com/neinseg" title="Gitlab">Gitlab</a>
<a href="https://chaos.social/@jaseg" title="Mastodon">Mastodon</a>
</span>
</nav>
<header>
<h1>wsdiff: Responsive diffs in plain HTML</h1>
<ul class="breadcrumbs">
<li><a href="/">jaseg.de</a></li>
<li><a href="/blog/">Blog</a></li><li><a href="/blog/wsdiff-static-html-diffs/">wsdiff: Responsive diffs in plain HTML</a></li>
</ul>
<strong>2025-07-25</strong>
</header>
<main data-pagefind-body>
<div class="document">
<div class="section" id="demo">
<h2>Demo</h2>
<p>First off, have a demo. Because of the width of this page, the output will show an unified diff. To try out the split
diff layout, make sure your browser window is wide enough and open the demo in a separate tab using <a class="reference external" href="/wsdiff-example.html">this link</a>.</p>
<p>wsdiff supports dark mode, try it out by toggling dark mode in your operating system!</p>
<iframe src="/wsdiff-example.html" style="width: 100%; height: 30em; border: 1px #d0d0d0 solid" id="wsdiff example diff"></iframe></div>
<div class="section" id="core-features">
<h2>Core Features</h2>
<p>There's many tools that render diffs on the web, but almost none that work well on small screens such as phones. I fixed
this by publishing <a class="reference external" href="https://pypi.org/project/wsdiff/">wsdiff</a>, a diffing tool written in Python that produces diffs
as beautiful, responsive, static, self-contained HTML pages. wsdiffs wrap text to fit the window, and dynamically switch
between unified and split diffs based on screen size using only CSS.</p>
<div class="section" id="responsive-line-wrapping">
<h3>Responsive Line Wrapping</h3>
<p>The first challenge I solved was wrapping source code lines to match the available screen space. Other tools often just
show horizontal scroll bars, which is an okay workaround when you're mostly working with hard-wrapped source code on a
laptop or desktop screen, but which results in catastrophic UX on any phone.</p>
<p>I solved line breaking with a combination of CSS-controlled, web-standard word breaking rules: <tt class="docutils literal"><span class="pre">overflow-wrap:</span>
anywhere</tt> for source code (<a class="reference external" href="https://developer.mozilla.org/en-US/docs/Web/CSS/overflow-wrap">MDN link</a>) and
<tt class="docutils literal"><span class="pre">white-space:</span> <span class="pre">pre-wrap</span></tt> to preserve whitespace accurately (<a class="reference external" href="https://developer.mozilla.org/en-US/docs/Web/CSS/white-space">MDN link</a>). To make both sides of the split diff align, and to
align line numbers with wrapped source code lines, the diff is laid out using a <a class="reference external" href="https://css-tricks.com/snippets/css/complete-guide-grid/">CSS grid layout</a>. In side-by-side
view, the layout has four columns: two for line numbers and two for the content. In unified view, the left ("old")
content column is dropped, and the deleted or modified lines that are highlighted in it in side-by-side view are slotted
into the remaining right column.</p>
<p>When soft-wrapping source code, text editors will often display a little curved arrow marker to indicate that a line was
soft-wrapped, and that there is not actually a newline character in the file at that location. wsdiff solves this
using the same technique I used for the soft-wrapping code blocks in this blog, described <a class="reference external" href="http://jaseg.de/blog/css-only-code-blocks/">here</a>. It inserts a string of <tt class="docutils literal"><span class="pre">"\a↳\a↳\a↳\a↳\a↳..."</span></tt> into the line number
element's <tt class="docutils literal">::after</tt> pseudo-element. This string evaluates to a sequence of unicode arrows separated by line breaks,
and starting with an empty line. The <tt class="docutils literal">::after</tt> pseudo-element is positioned using <tt class="docutils literal">position: absolute</tt>, and the
parent <tt class="docutils literal"><span <span class="pre">class="lineno"></span></tt> has <tt class="docutils literal">position: relative</tt> set. This way, the arrow pseudo-element gets placed on top
of the lineno span without affecting the layout at all. By setting <tt class="docutils literal">overflow: clip</tt> on the parent <tt class="docutils literal"><span
<span class="pre">class="lineno"></span></tt>, the arrow pseudo-element gets cut off vertically wherever the parent line number element naturally
ends. Since both the line and the line number element share a grid row, the line number element always matches the
height of the soft-wrapped line.</p>
</div>
<div class="section" id="responsive-split-unified-layout-selection">
<h3>Responsive Split/Unified Layout Selection</h3>
<p>To dynamically change between unified and side-by-side views, wsdiff uses a web-standard <a class="reference external" href="https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_media_queries/Using_media_queries">Media Query</a>. By default, the
page is laid out for side-by-side view. In the HTML source, the diff is listed as it is displayed in side-by-side view,
with the old and new lines along with their line numbers interleaved.</p>
<p>The magic happens when the media query gets triggered by a narrow screen width. The media query re-adjusts the layout in
four core steps:</p>
<blockquote>
<ol class="arabic simple">
<li>All unchanged lines in the left (old) column are hidden.</li>
<li>The left content column of the grid layout is hidden, so that now there are three columns: old line number, new line
number, and unified content.</li>
<li>All deleted or changed lines from the left (old) column are re-located to the right column. They naturally slot in
in the right spot because they already appear in the right order in the HTML source.</li>
<li>By slotting in the old lines in the right column, we have created gaps in the line number columns. Every deleted
line has an empty cell in the new line number column, and every inserted line has one in the old line number column.
The CSS adjusts the layout of these empty cells such that the border lines align nicely, and it overrides the
newline markers so that they only show in the right (new) line number column, not both.</li>
</ol>
</blockquote>
<p>Since this is all CSS, it happens automatically and near-instantly. Since it is using only web standard features, it
works across browsers and platforms.</p>
</div>
<div class="section" id="unchanged-line-folding-in-css">
<h3>Unchanged Line Folding in CSS</h3>
<p>When showing the diff of a large file, it is usually best to hide large runs of unchanged lines. wsdiff does this
similar to code folding in text editors. When a long run of unchanged lines is detected, a marker is placed spanning the
diff. This marker contains a checkbox that can be toggled to hide the unchanged lines. This feature is done completely
in CSS using a <tt class="docutils literal"><span class="pre">:has(input[type="checkbox"]:checked)</span></tt> selector.</p>
<p>The actual mechanics are quite simple. To cleanly hide the lines, they must be placed in a container <tt class="docutils literal"><div></tt>. That div
has a CSS subgrid layout using <tt class="docutils literal">display: grid; <span class="pre">grid-template-columns:</span> subgrid;</tt>, meaning that its contents align to
the surrounding diff grid.</p>
</div>
<div class="section" id="dark-mode">
<h3>Dark Mode</h3>
<p>Integrating a website with the OS-level dark mode is surprisingly easy. All you need is a <a class="reference external" href="https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_media_queries/Using_media_queries">Media Query</a> that selects
for <tt class="docutils literal">@media <span class="pre">(prefers-color-scheme:</span> dark)</tt> and you're good. wsdiff uses named colors using <a class="reference external" href="https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_cascading_variables/Using_CSS_custom_properties">CSS Custom Properties</a>, so
the actual dark mode media query only needs to override these color properties, and the rest of the CSS will be
re-computed automatically.</p>
</div>
<div class="section" id="limitations-text-selection">
<h3>Limitations: Text selection</h3>
<p>A limitation in having a combined, single HTML source for both side-by-side and unified diffs is that text selection
only works naturally in either mode. You can't make text selection work in both simultaneously without re-sorting the
lines in the HTML source, since there is no way to override the text selection order from pure CSS. In wsdiff, I worked
around this issue by just disabling text selection on the unchanged lines in the left (old) column, so selecting text in
the right column copies the unified diff as one would expect.</p>
</div>
</div>
<div class="section" id="try-it-yourself">
<h2>Try it yourself!</h2>
<p>You can find the demo from above at <a class="reference external" href="/wsdiff-example.html">this link</a>.</p>
<p>You can install wsdiff yourself <a class="reference external" href="https://pypi.org/project/wsdiff/">from PyPI</a>:</p>
<pre class="code sh literal-block">
<span class="lineno"></span><span class="line">$<span class="w"> </span>pip<span class="w"> </span>install<span class="w"> </span>-U<span class="w"> </span>wsdiff<span class="w"></span></span>
<span class="lineno"></span><span class="line"><span class="w"></span>Successfully<span class="w"> </span>installed<span class="w"> </span>wsdiff-0.3.1<span class="w"></span></span>
<span class="lineno"></span><span class="line"><span class="w"></span>$<span class="w"> </span>wsdiff<span class="w"> </span>old.py<span class="w"> </span>new.py<span class="w"> </span>-o<span class="w"> </span>diff.html
</span></pre>
</div>
</div>
</main><footer>
Copyright © 2025 Jan Sebastian Götte
/ <a href="/about/">About</a>
/ <a href="/imprint/">Imprint</a>
</footer>
<script type="text/javascript" src="/pagefind/pagefind-ui.js" defer></script>
<script>
window.addEventListener('DOMContentLoaded', (event) => {
new PagefindUI({element: "#search", showSubResults: true});
});
</script>
<script type="speculationrules">
{
"prerender": [
{
"source": "document",
"where": {
"and": [
{"href_matches": "/*"}
]
},
"eagerness": "moderate"
}
]
}
</script>
</body>
</html>
|