summaryrefslogtreecommitdiff
path: root/content/blog/wsdiff-static-html-diffs/index.rst
diff options
context:
space:
mode:
Diffstat (limited to 'content/blog/wsdiff-static-html-diffs/index.rst')
-rw-r--r--content/blog/wsdiff-static-html-diffs/index.rst127
1 files changed, 127 insertions, 0 deletions
diff --git a/content/blog/wsdiff-static-html-diffs/index.rst b/content/blog/wsdiff-static-html-diffs/index.rst
new file mode 100644
index 0000000..51f9175
--- /dev/null
+++ b/content/blog/wsdiff-static-html-diffs/index.rst
@@ -0,0 +1,127 @@
+---
+title: "wsdiff: Responsive diffs in plain HTML"
+date: 2025-07-25T23:42:00+01:00
+summary: >
+ 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 wsdiff, 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.
+---
+
+Demo
+----
+
+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 `this link
+</wsdiff-example.html>`__.
+
+wsdiff supports dark mode, try it out by toggling dark mode in your operating system!
+
+.. raw:: html
+
+ <iframe src="/wsdiff-example.html" style="width: 100%; height: 30em; border: 1px #d0d0d0 solid" id="wsdiff example diff"></iframe>
+
+Core Features
+-------------
+
+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 `wsdiff <https://pypi.org/project/wsdiff/>`__, 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.
+
+Responsive Line Wrapping
+........................
+
+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.
+
+I solved line breaking with a combination of CSS-controlled, web-standard word breaking rules: ``overflow-wrap:
+anywhere`` for source code (`MDN link <https://developer.mozilla.org/en-US/docs/Web/CSS/overflow-wrap>`__) and
+``white-space: pre-wrap`` to preserve whitespace accurately (`MDN link
+<https://developer.mozilla.org/en-US/docs/Web/CSS/white-space>`__). 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 `CSS grid layout`_. 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.
+
+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 `here <{{<ref
+"blog/css-only-code-blocks/index.rst">}}>`__. It inserts a string of ``"\a↳\a↳\a↳\a↳\a↳..."`` into the line number
+element's ``::after`` pseudo-element. This string evaluates to a sequence of unicode arrows separated by line breaks,
+and starting with an empty line. The ``::after`` pseudo-element is positioned using ``position: absolute``, and the
+parent ``<span class="lineno">`` has ``position: relative`` set. This way, the arrow pseudo-element gets placed on top
+of the lineno span without affecting the layout at all. By setting ``overflow: clip`` on the parent ``<span
+class="lineno">``, 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.
+
+Responsive Split/Unified Layout Selection
+.........................................
+
+To dynamically change between unified and side-by-side views, wsdiff uses a web-standard `Media Query`_. 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.
+
+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:
+
+ 1. All unchanged lines in the left (old) column are hidden.
+ 2. 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.
+ 3. 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.
+ 4. 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.
+
+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.
+
+Unchanged Line Folding in CSS
+.............................
+
+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 ``:has(input[type="checkbox"]:checked)`` selector.
+
+The actual mechanics are quite simple. To cleanly hide the lines, they must be placed in a container ``<div>``. That div
+has a CSS subgrid layout using ``display: grid; grid-template-columns: subgrid;``, meaning that its contents align to
+the surrounding diff grid.
+
+Dark Mode
+.........
+
+Integrating a website with the OS-level dark mode is surprisingly easy. All you need is a `Media Query`_ that selects
+for ``@media (prefers-color-scheme: dark)`` and you're good. wsdiff uses named colors using `CSS Custom Properties`_, 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.
+
+Limitations: Text selection
+...........................
+
+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.
+
+Try it yourself!
+----------------
+
+You can find the demo from above at `this link </wsdiff-example.html>`__.
+
+You can install wsdiff yourself `from PyPI <https://pypi.org/project/wsdiff/>`__:
+
+.. code:: sh
+
+ $ pip install -U wsdiff
+ Successfully installed wsdiff-0.3.1
+ $ wsdiff old.py new.py -o diff.html
+
+.. _`CSS grid layout`: https://css-tricks.com/snippets/css/complete-guide-grid/
+.. _`Media Query`: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_media_queries/Using_media_queries
+.. _`CSS Custom Properties`: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_cascading_variables/Using_CSS_custom_properties