diff options
author | jaseg <git@jaseg.de> | 2025-07-26 16:16:09 +0200 |
---|---|---|
committer | jaseg <git@jaseg.de> | 2025-07-26 16:16:09 +0200 |
commit | ccd6338503549e61b74b51c2904617edbfd604cc (patch) | |
tree | 2f7f721ef1920b37a86126e0ad13cbbc1c01f84c /content/blog/css-only-code-blocks/index.rst | |
parent | 02abb0154935c7525f17429a420bb43a261ea3bb (diff) | |
parent | df627459f2520e11b16ebd54e3a6ec95133599ad (diff) | |
download | blog-ccd6338503549e61b74b51c2904617edbfd604cc.tar.gz blog-ccd6338503549e61b74b51c2904617edbfd604cc.tar.bz2 blog-ccd6338503549e61b74b51c2904617edbfd604cc.zip |
deploy.py auto-commit
Diffstat (limited to 'content/blog/css-only-code-blocks/index.rst')
-rw-r--r-- | content/blog/css-only-code-blocks/index.rst | 209 |
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 18f3037..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 |