Add code listing CSS hack article
This commit is contained in:
parent
338de75fb4
commit
f5d03ed1cf
3 changed files with 286 additions and 57 deletions
217
content/blog/css-only-code-blocks/index.rst
Normal file
217
content/blog/css-only-code-blocks/index.rst
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue