diff options
-rw-r--r-- | content/blog/css-only-code-blocks/index.rst | 217 | ||||
-rwxr-xr-x | hack/rst2html | 5 | ||||
-rw-r--r-- | themes/conspiracy/assets/css/style.css | 129 | ||||
-rw-r--r-- | themes/conspiracy/layouts/_partials/head.html | 2 | ||||
-rw-r--r-- | themes/conspiracy/layouts/baseof.html | 17 |
5 files changed, 311 insertions, 59 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..2d39039 100644 --- a/themes/conspiracy/assets/css/style.css +++ b/themes/conspiracy/assets/css/style.css @@ -267,6 +267,14 @@ h3 { font-weight: 700; } +/* Prevent long literals from breaking the page layout's width */ +span.pre { + white-space: pre-wrap; + word-wrap: break-word; + overflow-x: auto; + overflow-wrap: anywhere; +} + body > header { z-index: 1; margin-top: 100px; @@ -471,9 +479,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 +508,7 @@ img:hover { white-space: pre-wrap; } +/* We render line numbers in CSS! */ .code > .lineno { counter-increment: lineno; word-break: keep-all; @@ -509,6 +523,7 @@ img:hover { align-self: stretch; } +/* We also handle line continuation markers in CSS. */ .code > .lineno::after { position: absolute; right: 5px; @@ -517,6 +532,7 @@ img:hover { color: var(--c-text-muted); } +/* Insert the actual line number */ .code > .lineno::before { content: counter(lineno); } @@ -533,62 +549,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 { diff --git a/themes/conspiracy/layouts/_partials/head.html b/themes/conspiracy/layouts/_partials/head.html index c82f260..026a312 100644 --- a/themes/conspiracy/layouts/_partials/head.html +++ b/themes/conspiracy/layouts/_partials/head.html @@ -9,7 +9,7 @@ {{- $stylesheet := resources.Get "css/style.css" | resources.ExecuteAsTemplate "style.css" . }} <link rel="stylesheet" href="{{ $stylesheet.RelPermalink }}"> - <link rel="preload" href="{{ (resources.Get "fonts/roboto_slab/RobotoSlab-VariableFont_wght.ttf").RelPermalink }}" as="font" type="font/woff2" crossorigin /> + <link rel="preload" href="{{ (resources.Get "fonts/roboto_slab/RobotoSlab-VariableFont_wght.ttf").RelPermalink }}" as="font" type="font/ttf" crossorigin /> <link rel="preload" href="{{ (resources.Get "fonts/nyght-serif-main/fonts/WEB/NyghtSerif-Regular.woff2").RelPermalink }}" as="font" type="font/woff2" crossorigin /> <link rel="preload" href="{{ (resources.Get "fonts/nyght-serif-main/fonts/WEB/NyghtSerif-Bold.woff2").RelPermalink }}" as="font" type="font/woff2" crossorigin /> <link rel="preload" href="{{ (resources.Get "fonts/nyght-serif-main/fonts/WEB/NyghtSerif-BoldItalic.woff2").RelPermalink }}" as="font" type="font/woff2" crossorigin /> diff --git a/themes/conspiracy/layouts/baseof.html b/themes/conspiracy/layouts/baseof.html index d8ebc93..32a5423 100644 --- a/themes/conspiracy/layouts/baseof.html +++ b/themes/conspiracy/layouts/baseof.html @@ -5,11 +5,26 @@ {{- partial "header.html" . -}} {{- block "main" . }}{{- end }} {{- partial "footer.html" . -}} - <script src="/pagefind/pagefind-ui.js" defer></script> + <script type="text/javascript" src="/pagefind/pagefind-ui.js" defer></script> <script> window.addEventListener('DOMContentLoaded', (event) => { new PagefindUI({element: "#search", showSubResults: true}); }); </script> + <script type="speculationrules"> + { + "prerender": [ + { + "source": "document", + "where": { + "and": [ + {"href_matches": "/*"} + ] + }, + "eagerness": "moderate" + } + ] + } + </script> </body> </html> |