summaryrefslogtreecommitdiff
path: root/content/blog/css-only-code-blocks
diff options
context:
space:
mode:
Diffstat (limited to 'content/blog/css-only-code-blocks')
-rw-r--r--content/blog/css-only-code-blocks/index.rst217
1 files changed, 217 insertions, 0 deletions
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 ``<span>`` elements. The resulting
+HTML for a code block then looks like this:
+
+.. code:: html
+
+ <pre class="code [language] literal-block">
+ <span class="lineno"></span>
+ <span class="line">
+ <span class="[syntax highlight token]">The </span><span class="[other syntax highlight token]">code!<span>
+ </span>
+ <!-- ... repeat once for each source line. -->
+ </pre>
+
+You can find the (rather short) source of the ``rst2html`` wrapper `below <rst2html_wrapper>`_.
+
+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 ``<pre>`` 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 ``<span class="lineno">`` 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 ``<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 lineno element naturally
+ ends.
+
+The line number span is inserted into the parent ``<pre>`` 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 ``<span class="line">`` in the right column, and set ``overflow-x:
+auto`` on the listing ``<pre>``. 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('<span class="lineno"></span><span class="line">')
+
+ def depart_literal_block(self, node):
+ self.in_literal_block = False
+ self.body.append('\n</span></pre>\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'</span>\n<span class="lineno"></span><span class="line">')
+ 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