summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorjaseg <git@jaseg.de>2025-07-26 16:03:33 +0200
committerjaseg <git@jaseg.de>2025-07-26 16:03:33 +0200
commitf5d03ed1cf5e98581f10f1e7922ed77a5e6ffd76 (patch)
tree8a355165711d3e23c4e826233578c92c77facba8
parent338de75fb45b861510e99a8271724fd00f2b023e (diff)
downloadblog-f5d03ed1cf5e98581f10f1e7922ed77a5e6ffd76.tar.gz
blog-f5d03ed1cf5e98581f10f1e7922ed77a5e6ffd76.tar.bz2
blog-f5d03ed1cf5e98581f10f1e7922ed77a5e6ffd76.zip
Add code listing CSS hack article
-rw-r--r--content/blog/css-only-code-blocks/index.rst217
-rwxr-xr-xhack/rst2html5
-rw-r--r--themes/conspiracy/assets/css/style.css121
3 files changed, 286 insertions, 57 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
diff --git a/hack/rst2html b/hack/rst2html
index e3a390d..f9c9146 100755
--- a/hack/rst2html
+++ b/hack/rst2html
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-# https://gist.github.com/mastbaum/2655700
+# Based on https://gist.github.com/mastbaum/2655700 for the basic plugin scaffolding
import sys
import re
@@ -17,6 +17,9 @@ class UnfuckedHTMLTranslator(HTMLTranslator):
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">')
diff --git a/themes/conspiracy/assets/css/style.css b/themes/conspiracy/assets/css/style.css
index 11654ea..2165255 100644
--- a/themes/conspiracy/assets/css/style.css
+++ b/themes/conspiracy/assets/css/style.css
@@ -471,9 +471,14 @@ img:hover {
filter: none;
}
+/*****************************************************/
+/* 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;
@@ -495,6 +500,7 @@ img:hover {
white-space: pre-wrap;
}
+/* We render line numbers in CSS! */
.code > .lineno {
counter-increment: lineno;
word-break: keep-all;
@@ -509,6 +515,7 @@ img:hover {
align-self: stretch;
}
+/* We also handle line continuation markers in CSS. */
.code > .lineno::after {
position: absolute;
right: 5px;
@@ -517,6 +524,7 @@ img:hover {
color: var(--c-text-muted);
}
+/* Insert the actual line number */
.code > .lineno::before {
content: counter(lineno);
}
@@ -533,62 +541,63 @@ footer {
padding: 5px 25px 5px 25px;
}
-body .hll {}
-body .c { color: var(--c-text); font-weight: 400 } /* Comment */
-body .n { color: var(--c-text); font-weight: 400 } /* Name */
-body .o { color: var(--c-text); font-weight: 400 } /* Operator */
-body .cm { color: var(--c-text); font-weight: 400 } /* Comment.Multiline */
-body .cp { color: var(--c-text); font-weight: 400 } /* Comment.Preproc */
-body .c1 { color: var(--c-text); font-weight: 400 } /* Comment.Single */
-body .cs { color: var(--c-text); font-weight: 400 } /* Comment.Special */
-body .nd { color: var(--c-text); font-weight: 400 } /* Name.Decorator */
-body .nn { color: var(--c-text); font-weight: 400 } /* Name.Namespace */
-body .vc { color: var(--c-text); font-weight: 400 } /* Name.Variable.Class */
-body .vg { color: var(--c-text); font-weight: 400 } /* Name.Variable.Global */
-body .vi { color: var(--c-text); font-weight: 400 } /* Name.Variable.Instance */
-body .err { color: var(--c-text-highlight); font-weight: 500 } /* Error */
-body .k { color: var(--c-text-highlight); font-weight: 500 } /* Keyword */
-body .l { color: var(--c-text-highlight); font-weight: 500 } /* Literal */
-body .kc { color: var(--c-text-highlight); font-weight: 500 } /* Keyword.Constant */
-body .kd { color: var(--c-text-highlight); font-weight: 500 } /* Keyword.Declaration */
-body .kn { color: var(--c-text-highlight); font-weight: 500 } /* Keyword.Namespace */
-body .kp { color: var(--c-text-highlight); font-weight: 500 } /* Keyword.Pseudo */
-body .kr { color: var(--c-text-highlight); font-weight: 500 } /* Keyword.Reserved */
-body .kt { color: var(--c-text-highlight); font-weight: 500 } /* Keyword.Type */
-body .na { color: var(--c-text-highlight); font-weight: 500 } /* Name.Attribute */
-body .nb { color: var(--c-text-highlight); font-weight: 500 } /* Name.Builtin */
-body .nc { color: var(--c-text-highlight); font-weight: 500 } /* Name.Class */
-body .no { color: var(--c-text-highlight); font-weight: 500 } /* Name.Constant */
-body .ni { color: var(--c-text-highlight); font-weight: 500 } /* Name.Entity */
-body .ne { color: var(--c-text-highlight); font-weight: 500 } /* Name.Exception */
-body .nf { color: var(--c-text-highlight); font-weight: 500 } /* Name.Function */
-body .nl { color: var(--c-text-highlight); font-weight: 500 } /* Name.Label */
-body .nx { color: var(--c-text-highlight); font-weight: 500 } /* Name.Other */
-body .py { color: var(--c-text-highlight); font-weight: 500 } /* Name.Property */
-body .nt { color: var(--c-text-highlight); font-weight: 500 } /* Name.Tag */
-body .nv { color: var(--c-text-highlight); font-weight: 500 } /* Name.Variable */
-body .ow { color: var(--c-text-highlight); font-weight: 500 } /* Operator.Word */
-body .bp { color: var(--c-text-highlight); font-weight: 500 } /* Name.Builtin.Pseudo */
-body .ld { color: var(--c-text); font-weight: 600 } /* Literal.Date */
-body .m { color: var(--c-text); font-weight: 600 } /* Literal.Number */
-body .s { color: var(--c-text); font-weight: 600 } /* Literal.String */
-body .mb { color: var(--c-text); font-weight: 600 } /* Literal.Number.Bin */
-body .mf { color: var(--c-text); font-weight: 600 } /* Literal.Number.Float */
-body .mh { color: var(--c-text); font-weight: 600 } /* Literal.Number.Hex */
-body .mi { color: var(--c-text); font-weight: 600 } /* Literal.Number.Integer */
-body .mo { color: var(--c-text); font-weight: 600 } /* Literal.Number.Oct */
-body .sb { color: var(--c-text); font-weight: 600 } /* Literal.String.Backtick */
-body .sc { color: var(--c-text); font-weight: 600 } /* Literal.String.Char */
-body .sd { color: var(--c-text); font-weight: 600 } /* Literal.String.Doc */
-body .s2 { color: var(--c-text); font-weight: 600 } /* Literal.String.Double */
-body .se { color: var(--c-text); font-weight: 600 } /* Literal.String.Escape */
-body .sh { color: var(--c-text); font-weight: 600 } /* Literal.String.Heredoc */
-body .si { color: var(--c-text); font-weight: 600 } /* Literal.String.Interpol */
-body .sx { color: var(--c-text); font-weight: 600 } /* Literal.String.Other */
-body .sr { color: var(--c-text); font-weight: 600 } /* Literal.String.Regex */
-body .s1 { color: var(--c-text); font-weight: 600 } /* Literal.String.Single */
-body .ss { color: var(--c-text); font-weight: 600 } /* Literal.String.Symbol */
-body .il { color: var(--c-text); font-weight: 600 } /* Literal.Number.Integer.Long */
+/* Token styling rules for syntax highlighting */
+.code .hll {}
+.code .c { color: var(--c-text); font-weight: 400 } /* Comment */
+.code .n { color: var(--c-text); font-weight: 400 } /* Name */
+.code .o { color: var(--c-text); font-weight: 400 } /* Operator */
+.code .cm { color: var(--c-text); font-weight: 400 } /* Comment.Multiline */
+.code .cp { color: var(--c-text); font-weight: 400 } /* Comment.Preproc */
+.code .c1 { color: var(--c-text); font-weight: 400 } /* Comment.Single */
+.code .cs { color: var(--c-text); font-weight: 400 } /* Comment.Special */
+.code .nd { color: var(--c-text); font-weight: 400 } /* Name.Decorator */
+.code .nn { color: var(--c-text); font-weight: 400 } /* Name.Namespace */
+.code .vc { color: var(--c-text); font-weight: 400 } /* Name.Variable.Class */
+.code .vg { color: var(--c-text); font-weight: 400 } /* Name.Variable.Global */
+.code .vi { color: var(--c-text); font-weight: 400 } /* Name.Variable.Instance */
+.code .err { color: var(--c-text-highlight); font-weight: 500 } /* Error */
+.code .k { color: var(--c-text-highlight); font-weight: 500 } /* Keyword */
+.code .l { color: var(--c-text-highlight); font-weight: 500 } /* Literal */
+.code .kc { color: var(--c-text-highlight); font-weight: 500 } /* Keyword.Constant */
+.code .kd { color: var(--c-text-highlight); font-weight: 500 } /* Keyword.Declaration */
+.code .kn { color: var(--c-text-highlight); font-weight: 500 } /* Keyword.Namespace */
+.code .kp { color: var(--c-text-highlight); font-weight: 500 } /* Keyword.Pseudo */
+.code .kr { color: var(--c-text-highlight); font-weight: 500 } /* Keyword.Reserved */
+.code .kt { color: var(--c-text-highlight); font-weight: 500 } /* Keyword.Type */
+.code .na { color: var(--c-text-highlight); font-weight: 500 } /* Name.Attribute */
+.code .nb { color: var(--c-text-highlight); font-weight: 500 } /* Name.Builtin */
+.code .nc { color: var(--c-text-highlight); font-weight: 500 } /* Name.Class */
+.code .no { color: var(--c-text-highlight); font-weight: 500 } /* Name.Constant */
+.code .ni { color: var(--c-text-highlight); font-weight: 500 } /* Name.Entity */
+.code .ne { color: var(--c-text-highlight); font-weight: 500 } /* Name.Exception */
+.code .nf { color: var(--c-text-highlight); font-weight: 500 } /* Name.Function */
+.code .nl { color: var(--c-text-highlight); font-weight: 500 } /* Name.Label */
+.code .nx { color: var(--c-text-highlight); font-weight: 500 } /* Name.Other */
+.code .py { color: var(--c-text-highlight); font-weight: 500 } /* Name.Property */
+.code .nt { color: var(--c-text-highlight); font-weight: 500 } /* Name.Tag */
+.code .nv { color: var(--c-text-highlight); font-weight: 500 } /* Name.Variable */
+.code .ow { color: var(--c-text-highlight); font-weight: 500 } /* Operator.Word */
+.code .bp { color: var(--c-text-highlight); font-weight: 500 } /* Name.Builtin.Pseudo */
+.code .ld { color: var(--c-text); font-weight: 600 } /* Literal.Date */
+.code .m { color: var(--c-text); font-weight: 600 } /* Literal.Number */
+.code .s { color: var(--c-text); font-weight: 600 } /* Literal.String */
+.code .mb { color: var(--c-text); font-weight: 600 } /* Literal.Number.Bin */
+.code .mf { color: var(--c-text); font-weight: 600 } /* Literal.Number.Float */
+.code .mh { color: var(--c-text); font-weight: 600 } /* Literal.Number.Hex */
+.code .mi { color: var(--c-text); font-weight: 600 } /* Literal.Number.Integer */
+.code .mo { color: var(--c-text); font-weight: 600 } /* Literal.Number.Oct */
+.code .sb { color: var(--c-text); font-weight: 600 } /* Literal.String.Backtick */
+.code .sc { color: var(--c-text); font-weight: 600 } /* Literal.String.Char */
+.code .sd { color: var(--c-text); font-weight: 600 } /* Literal.String.Doc */
+.code .s2 { color: var(--c-text); font-weight: 600 } /* Literal.String.Double */
+.code .se { color: var(--c-text); font-weight: 600 } /* Literal.String.Escape */
+.code .sh { color: var(--c-text); font-weight: 600 } /* Literal.String.Heredoc */
+.code .si { color: var(--c-text); font-weight: 600 } /* Literal.String.Interpol */
+.code .sx { color: var(--c-text); font-weight: 600 } /* Literal.String.Other */
+.code .sr { color: var(--c-text); font-weight: 600 } /* Literal.String.Regex */
+.code .s1 { color: var(--c-text); font-weight: 600 } /* Literal.String.Single */
+.code .ss { color: var(--c-text); font-weight: 600 } /* Literal.String.Symbol */
+.code .il { color: var(--c-text); font-weight: 600 } /* Literal.Number.Integer.Long */
@media (max-width: 40em) {
nav > div {