summaryrefslogtreecommitdiff
path: root/content/blog/css-only-code-blocks
diff options
context:
space:
mode:
authorjaseg <git@jaseg.de>2025-07-26 17:53:39 +0200
committerjaseg <git@jaseg.de>2025-07-26 17:53:39 +0200
commiteefa5cbb554017a41095e54e6c39592bc1de1761 (patch)
tree2f2ff3ffe57b1b3a3c11560b3ee79b1480efa4e7 /content/blog/css-only-code-blocks
parent3fd0c107e02858843314107afbbeaf5c056f785f (diff)
parentccd6338503549e61b74b51c2904617edbfd604cc (diff)
downloadblog-eefa5cbb554017a41095e54e6c39592bc1de1761.tar.gz
blog-eefa5cbb554017a41095e54e6c39592bc1de1761.tar.bz2
blog-eefa5cbb554017a41095e54e6c39592bc1de1761.zip
deploy.py auto-commitdeploy
Diffstat (limited to 'content/blog/css-only-code-blocks')
-rw-r--r--content/blog/css-only-code-blocks/index.rst209
1 files changed, 0 insertions, 209 deletions
diff --git a/content/blog/css-only-code-blocks/index.rst b/content/blog/css-only-code-blocks/index.rst
deleted file mode 100644
index c9dc2fe..0000000
--- a/content/blog/css-only-code-blocks/index.rst
+++ /dev/null
@@ -1,209 +0,0 @@
----
-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