diff options
author | jaseg <git@jaseg.de> | 2025-07-26 16:03:33 +0200 |
---|---|---|
committer | jaseg <git@jaseg.de> | 2025-07-26 16:03:33 +0200 |
commit | f5d03ed1cf5e98581f10f1e7922ed77a5e6ffd76 (patch) | |
tree | 8a355165711d3e23c4e826233578c92c77facba8 /content/blog | |
parent | 338de75fb45b861510e99a8271724fd00f2b023e (diff) | |
download | blog-f5d03ed1cf5e98581f10f1e7922ed77a5e6ffd76.tar.gz blog-f5d03ed1cf5e98581f10f1e7922ed77a5e6ffd76.tar.bz2 blog-f5d03ed1cf5e98581f10f1e7922ed77a5e6ffd76.zip |
Add code listing CSS hack article
Diffstat (limited to 'content/blog')
-rw-r--r-- | content/blog/css-only-code-blocks/index.rst | 217 |
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 |