summaryrefslogtreecommitdiff
path: root/blog/wsdiff-static-html-diffs/index.html
diff options
context:
space:
mode:
Diffstat (limited to 'blog/wsdiff-static-html-diffs/index.html')
-rw-r--r--blog/wsdiff-static-html-diffs/index.html169
1 files changed, 169 insertions, 0 deletions
diff --git a/blog/wsdiff-static-html-diffs/index.html b/blog/wsdiff-static-html-diffs/index.html
new file mode 100644
index 0000000..5bccd82
--- /dev/null
+++ b/blog/wsdiff-static-html-diffs/index.html
@@ -0,0 +1,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 (&quot;old&quot;)
+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">&quot;\a↳\a↳\a↳\a↳\a↳...&quot;</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">&lt;span <span class="pre">class=&quot;lineno&quot;&gt;</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">&lt;span
+<span class="pre">class=&quot;lineno&quot;&gt;</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=&quot;checkbox&quot;]: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">&lt;div&gt;</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">&#64;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>