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:
<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.
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 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;
}
.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:
- 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.
- 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.
- 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.
- 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.
#!/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)