1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
|
---
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;
}
.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
|