diff options
Diffstat (limited to 'blog/wsdiff-static-html-diffs/index.html')
-rw-r--r-- | blog/wsdiff-static-html-diffs/index.html | 169 |
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 ("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> |