From f5d03ed1cf5e98581f10f1e7922ed77a5e6ffd76 Mon Sep 17 00:00:00 2001 From: jaseg Date: Sat, 26 Jul 2025 16:03:33 +0200 Subject: Add code listing CSS hack article --- content/blog/css-only-code-blocks/index.rst | 217 ++++++++++++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 content/blog/css-only-code-blocks/index.rst (limited to 'content/blog/css-only-code-blocks') diff --git a/content/blog/css-only-code-blocks/index.rst b/content/blog/css-only-code-blocks/index.rst new file mode 100644 index 0000000..666af9f --- /dev/null +++ b/content/blog/css-only-code-blocks/index.rst @@ -0,0 +1,217 @@ +--- +title: "Code listings with nice line wrapping and line numbers from plain CSS" +date: 2025-07-23T23:42:00+01:00 +summary: > + Code listings in web pages are often a bit of a pain to use. Usually, they don't wrap on small screens. Also, + copy-pasting code from a code listing often copies the line numbers along with the code. Finally, many + implementations use heavyweight HTML and/or javascript, making them slow to render. For this blog, I wrote a little + CSS hack that renders nice, wrapping code blocks with line continuation markers in plain CSS without any JS. +--- + +Code listings in web pages are often a bit of a pain to use. Often, they don't wrap on small screens. Also, copy-pasting +code from a code listing often copies the line numbers along with the code. Finally, many implementations use +heavyweight HTML and/or javascript, making them slow to render (looking at you, gitlab). + +For this blog, I wrote an implementation that renders HTML code listings entirely without JavaScript, renders line +numbers using plain CSS such that they don't get selected with the code, and that works with the browser to wrap in a +natural way while still supporting the little line continuation arrows that are used to show that a line was soft +wrapped in text editors. + +This blog is rendered as a static site using Hugo_ from a pile of RestructuredText_ documents. RestructuredText renders +code listings using Pygments_ by default. Pygments hard-bakes the line numbers into the generated HTML, so I am using a +`monkey-patched`_ hook that changes the line number rendering to just a bunch of empty ```` elements. The resulting +HTML for a code block then looks like this: + +.. code:: html + +
+        
+        
+            The code!
+        
+        
+    
+ +You can find the (rather short) source of the ``rst2html`` wrapper `below `_. + +The CSS +------- + +This modified HTML structure of the code listing gets accompanied by some CSS to make it flow nicely. Here is a listing +of the complete CSS controlling the listing. The only bit that isn't included here is the actual syntax styling rules +for the pygments tokens. + +.. code:: css + + /*****************************************************/ + /* Code block formatting / syntax highlighting rules */ + /*****************************************************/ + + .code { + font-family: "Fira Code"; + font-size: 13px; + text-align: left; /* Override default content "justify" alignment */ + white-space: pre-wrap; + word-wrap: break-word; + overflow-x: auto; + display: grid; + align-items: start; + grid-template-columns: min-content 1fr; + } + + .code > .line { + padding-left: calc(2em + 5px); + text-indent: -2em; + padding-top: 2px; + min-width: 15em; + } + + /* Make individual syntax tokens wrap anywhere */ + .code > .line > span { + overflow-wrap: anywhere; + white-space: pre-wrap; + } + + /* We render line numbers in CSS! */ + .code > .lineno { + counter-increment: lineno; + word-break: keep-all; + margin: 0; + padding-left: 15px; + padding-right: 5px; + overflow: clip; + position: relative; + text-align: right; + color: var(--c-text-muted); + border-right: 1px solid var(--c-fg-highlight); + align-self: stretch; + } + + /* We also handle line continuation markers in CSS. */ + .code > .lineno::after { + position: absolute; + right: 5px; + content: "\a↳\a↳\a↳\a↳\a↳\a↳\a↳\a↳\a↳\a↳\a↳\a↳\a↳\a↳\a↳\a↳\a↳\a↳"; + white-space: pre; + color: var(--c-text-muted); + } + + /* Insert the actual line number */ + .code > .lineno::before { + content: counter(lineno); + } + + .code::before { + counter-reset: lineno; + } + + footer { + margin-top: 30px; + align-self: center; + margin-bottom: 10px; + text-align: center; + padding: 5px 25px 5px 25px; + } + + .code .hll {} + /* Following are about 50 lines that define the styling of each kind of pygments syntax highlight token. These lines + all look like the following: */ + .code .c { color: var(--c-text); font-weight: 400 } /* Comment */ + +This CSS does a few things: + + 1. It renders the ``
`` code listing element using a two-column CSS ``display: grid`` layout. The left column is
+    used for the line numbers, and the right column is used for the code lines.
+ 2. It numbers the lines using a `CSS Counter`_. CSS counters are meant for things like numbering headings and such, but
+    they are a perfect fit for our purpose.
+ 3. It inserts the counter value as the line number into the ```` element's ``::before``
+    pseudo-element. A side effect of using the ``::before`` pseudo-element is that without doing anything extra, the
+    line numbers will remain outside of the normal text selection so they will neither be highlighted when selecting
+    listing content, nor will they be copied when copy/pasting the listing content.
+ 4. It inserts a string of ``"\a↳\a↳\a↳\a↳\a↳\a↳\a↳\a↳\a↳\a↳\a↳\a↳\a↳\a↳\a↳\a↳\a↳\a↳"`` into the line number span'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 ```` 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 ````, the arrow pseudo-element gets cut off vertically wherever the parent lineno element naturally
+    ends.
+
+The line number span is inserted into the parent ``
`` element's CSS grid using ``align-self: stretch``, which
+causes it to vertically stretch to fill the available space. Since the line number span only contains the line number,
+its minimum height is a single line. As a result, it will stretch higher only when the corresponding code line in the
+right grid column stretches vertically because of line wrapping. When that happens, part of the arrow pseudo-element
+starts showing through from behind the ``overflow: clip`` of the line number span, and one arrow gets rendered for each
+wrapped listing line.
+
+When the page is too narrow, we don't want the code listing's lines to wrapp into a column of single characters. To
+prevent that, we simply set a ``min-width`` on the ```` in the right column, and set ``overflow-x:
+auto`` on the listing ``
``. This results in a horizontal scroll bar appearing whenever the listing gets too narrow.
+
+You can try out the line wrapping by resizing this page!
+
+rst2html wrapper
+----------------
+
+Here is the python ``rst2html`` wrapper that monkey-patches code rendering. I made hugo invoke this while building the
+page by simply overriding the ``PATH`` environment variable.
+
+.. code:: python
+
+    #!/usr/bin/env python3
+    # Based on https://gist.github.com/mastbaum/2655700 for the basic plugin scaffolding
+
+    import sys
+    import re
+
+    import docutils.core
+    from docutils.transforms import Transform
+    from docutils.nodes import TextElement, Inline, Text
+    from docutils.parsers.rst import Directive, directives
+    from docutils.writers.html4css1 import Writer, HTMLTranslator
+
+
+    class UnfuckedHTMLTranslator(HTMLTranslator):
+        def __init__(self, *args, **kwargs):
+            super().__init__(*args, **kwargs)
+            self.in_literal_block = False
+
+        def visit_literal_block(self, node):
+            # Insert an empty "lineno" span before each line. We insert the line numbers using pure CSS in a ::before
+            # pseudo-element. This has the added advantage that the line numbers don't get included in text selection.
+            # These line number spans are also used to show line continuation markers when a line is wrapped.
+            self.in_literal_block = True
+            self.body.append(self.starttag(node, 'pre', CLASS='literal-block'))
+            self.body.append('')
+
+        def depart_literal_block(self, node):
+            self.in_literal_block = False
+            self.body.append('\n
\n') + + def visit_Text(self, node): + if self.in_literal_block: + for match in re.finditer('([^\n]*)(\n|$)', node.astext()): + text, end = match.groups() + + if text: + super().visit_Text(Text(text)) + + if end == '\n': + if isinstance(node.parent, Inline): + self.depart_inline(node.parent) + self.body.append(f'
\n') + if isinstance(node.parent, Inline): + self.visit_inline(node.parent) + + else: + super().visit_Text(node) + + + html_writer = Writer() + html_writer.translator_class = UnfuckedHTMLTranslator + docutils.core.publish_cmdline(writer=html_writer) + +.. _Hugo: https://gohugo.io/ +.. _RestructuredText: https://www.sphinx-doc.org/en/master/usage/restructuredtext/index.html +.. _Pygments: https://pygments.org/ +.. _`monkey-patched`: https://en.wikipedia.org/wiki/Monkey_patch +.. _`CSS Counter`: https://developer.mozilla.org/en-US/docs/Web/CSS/counter -- cgit