Add syntax highlighting on code blocks, plus a Code Highlighting post

Colors <pre><code> blocks site-wide via vendored highlight.js v11.11.1
(pinned to that stable tag, not main, which tracks an in-progress
11.0.0-beta1), auto-detected and restricted to
configure({ languages: ['php', 'bash', 'xml', 'css', 'python',
'javascript', 'yaml', 'json', 'ini'] }) - no per-block markup needed for
the ~60 existing code blocks across the site. css/python/javascript ship
in the core bundle; yaml/json/ini (ini covers .env-style files too)
don't and are vendored as separate per-language files.

Themes swap with the existing dark/light toggle: ir-black (dark) +
github (light, resolving the entry's own open question), via the same
data-theme-driven mechanism as the main palette
(syntax-highlight-init.twig/syntax-highlight.twig, mirroring
theme-init.twig/nav.twig's split) - a MutationObserver swaps the theme
link live without touching the existing toggle button's click handler.

Twig-syntax code blocks have no highlight.js grammar and are marked
class="nohighlight" by hand (15 blocks across 9 files, found by grepping
for literal {% %}/{{ }} syntax rather than guessing) rather than
force-matched into the restricted candidate set, which would color them
wrong instead of leaving them plain.

One correction to the original backlog entry's suggested approach: it
suggested vendoring highlight.js under novaconium/vendor/ next to Twig.
That would have silently 404ed on every request - Twig is server-side
PHP, never fetched by a browser, but highlight.js's .js/.css files are,
and only public/ is web-reachable. Vendored to public/vendor/highlightjs/
instead; documented in AGENTS.md and a new upgrading-highlightjs doc,
since public/ isn't touched by the usual novaconium/-swap framework
update workflow, so a future highlight.js bump won't propagate to
existing projects automatically the way it does for everything else
under novaconium/.

Caught two real bugs via testing rather than review: hljs.highlightAll()
silently no-ops if called before the document finishes parsing rather
than deferring itself, and a bash example starting with the word "php"
auto-detects as PHP, not bash.

Also adds App/pages/blog/code-highlighting/ - a new blog post
demonstrating the feature with a verified worked example in each of the
nine languages, plus how to force a language via an explicit
language-<name> class when auto-detection isn't enough.

Closes the "Syntax highlighting on code blocks" backlog item in
novaconium/ISSUES.md.
This commit is contained in:
code
2026-07-14 19:00:48 +00:00
parent 74c0d59019
commit cb64836901
30 changed files with 1712 additions and 65 deletions
@@ -0,0 +1,90 @@
{% extends layout %}
{% import '_layout/icons.twig' as icons %}
{% block title %}Code Highlighting{% endblock %}
{% block description %}How syntax highlighting works on this site, with worked examples in bash, HTML, CSS, YAML, Python, JavaScript, JSON, and INI/env.{% endblock %}
{% block robots %}index, follow{% endblock %}
{% block tags %}highlighting, reference{% endblock %}
{% block canonical %}{{ request_path|default('/') }}{% endblock %}
{% block og_type %}article{% endblock %}
{% block og_title %}{{ block('title') }}{% endblock %}
{% block og_description %}{{ block('description') }}{% endblock %}
{% block og_url %}{{ block('canonical') }}{% endblock %}
{% block twitter_card %}summary{% endblock %}
{% block twitter_title %}{{ block('title') }}{% endblock %}
{% block twitter_description %}{{ block('description') }}{% endblock %}
{% block blog_content %}
<h1>Code Highlighting</h1>
<p>Every <code>&lt;pre&gt;&lt;code&gt;</code> block on this site gets colored automatically via vendored <a href="https://highlightjs.org/">highlight.js</a> — see <a class="icon-link" href="/admin/docs/styling">{{ icons.book() }}Styling</a> for the mechanism and <a class="icon-link" href="/admin/docs/upgrading-highlightjs">{{ icons.book() }}Upgrading highlight.js</a> for what's vendored. This post is a plain reference: how to write a code block, and one worked example in each language this site highlights.</p>
<h2>How to use it</h2>
<p>Write a normal code block — nothing extra required, the language is auto-detected:</p>
<pre><code class="nohighlight">&lt;pre&gt;&lt;code&gt;your code here&lt;/code&gt;&lt;/pre&gt;</code></pre>
<p>Auto-detection is restricted to the languages this site actually uses (see <code>hljs.configure(...)</code> in <code>novaconium/pages/_layout/syntax-highlight.twig</code>) so it doesn't misfire trying to match dozens of unrelated bundled languages against a short snippet. If a snippet is ambiguous, or ever gets detected as the wrong language, force it with an explicit <code>language-&lt;name&gt;</code> class instead of relying on auto-detection:</p>
<pre><code class="nohighlight">&lt;pre&gt;&lt;code class="language-yaml"&gt;your code here&lt;/code&gt;&lt;/pre&gt;</code></pre>
<p>A code block written in Twig template syntax (<code>{{ '{%' }} ... {{ '%}' }}</code>, <code>{{ '{{' }} ... {{ '}}' }}</code>) has no highlight.js grammar to match — mark those <code>class="nohighlight"</code> instead, the same way the two snippets above are marked (they're showing literal HTML as plain text, not being highlighted as HTML themselves). See <a class="icon-link" href="/blog/twig-syntax-guide">{{ icons.book() }}the Twig Syntax Guide</a> for Twig's own syntax, written the same way.</p>
<h2>Bash</h2>
<pre><code>#!/bin/bash
for f in App/pages/blog/*/index.twig; do
echo "Post: $f"
done</code></pre>
<h2>HTML</h2>
<pre><code>&lt;article class="post"&gt;
&lt;h1&gt;Hello, World!&lt;/h1&gt;
&lt;p&gt;A short excerpt.&lt;/p&gt;
&lt;/article&gt;</code></pre>
<h2>CSS</h2>
<pre><code>.post-list li {
border-bottom: 1px solid var(--border-color);
padding: 0.75rem 0;
}</code></pre>
<h2>YAML</h2>
<pre><code>site:
name: Novaconium Website
theme: dark
tags:
- php
- twig
- highlighting</code></pre>
<h2>Python</h2>
<pre><code>def excerpt(text, length=140):
return text[:length].rsplit(" ", 1)[0] + "..."</code></pre>
<h2>JavaScript</h2>
<pre><code>function toggleTheme() {
const html = document.documentElement;
const next = html.dataset.theme === "light" ? "dark" : "light";
html.setAttribute("data-theme", next);
}</code></pre>
<h2>JSON</h2>
<pre><code>{
"title": "Code Highlighting",
"tags": ["highlighting", "reference"],
"published": true
}</code></pre>
<h2>INI / .env</h2>
<pre><code>; App/config.php equivalent, .ini-style
[matomo]
url = https://matomo.example.com/
site_id = 1</code></pre>
<p>YAML, JSON, and INI aren't part of highlight.js's core bundle the way PHP/Bash/CSS/Python/JavaScript/XML are — they're vendored as three separate files under <code>public/vendor/highlightjs/languages/</code>, loaded after the core bundle. See <a class="icon-link" href="/admin/docs/upgrading-highlightjs">{{ icons.book() }}Upgrading highlight.js</a> for how to add another language the same way.</p>
{% endblock %}
+6
View File
@@ -34,5 +34,11 @@ return [
'excerpt' => "A showcase of this theme's default styling for headings, lists, tables, code, and other common HTML elements.",
'published' => '2026-07-13',
],
[
'slug' => 'code-highlighting',
'title' => 'Code Highlighting',
'excerpt' => 'How syntax highlighting works on this site, with worked examples in bash, HTML, CSS, YAML, Python, JavaScript, JSON, and INI/env.',
'published' => '2026-07-14',
],
],
];
+1 -1
View File
@@ -83,7 +83,7 @@
<h2>Code</h2>
<p>Inline: <code>(new Mailer())-&gt;send($old['name'], $old['email'], $old['message']);</code></p>
<pre><code>{% verbatim %}{% extends layout %}
<pre><code class="nohighlight">{% verbatim %}{% extends layout %}
{% block content %}
...
+6 -6
View File
@@ -24,7 +24,7 @@
<h2>Output &amp; variables</h2>
<p>Twig prints an expression with <code>{{ '{{ ... }}' }}</code>. Sidecar data, route params, and a handful of framework-provided variables (<code>request_path</code>, <code>layout</code>, <code>site_name</code>) are all just variables in scope:</p>
<pre><code>{% verbatim %}{{ title }}
<pre><code class="nohighlight">{% verbatim %}{{ title }}
{{ params.slug }}
{{ post.title }}{% endverbatim %}</code></pre>
<p>Dot notation (<code>post.title</code>) works whether <code>post</code> is an array key or an object property — Twig tries both, so templates don't need to care which.</p>
@@ -46,29 +46,29 @@
<h2>Control structures</h2>
<p>The two workhorses are <code>{% verbatim %}{% if %}{% endverbatim %}</code> and <code>{% verbatim %}{% for %}{% endverbatim %}</code>:</p>
<pre><code>{% verbatim %}{% if sent %}
<pre><code class="nohighlight">{% verbatim %}{% if sent %}
&lt;p&gt;Thanks — your message has been sent.&lt;/p&gt;
{% endif %}{% endverbatim %}</code></pre>
<pre><code>{% verbatim %}{% for post in posts %}
<pre><code class="nohighlight">{% verbatim %}{% for post in posts %}
&lt;li&gt;&lt;a href="/blog/{{ post.slug }}"&gt;{{ post.title }}&lt;/a&gt;&lt;/li&gt;
{% endfor %}{% endverbatim %}</code></pre>
<p>This exact loop is what renders the <a href="/blog">blog listing page</a> you probably followed a link from to get here.</p>
<h2>Comments</h2>
<p>Anything between <code>{% verbatim %}{# and #}{% endverbatim %}</code> is stripped entirely from the output — unlike an HTML comment, it never reaches the browser:</p>
<pre><code>{% verbatim %}{# Open Graph / Facebook #}{% endverbatim %}</code></pre>
<pre><code class="nohighlight">{% verbatim %}{# Open Graph / Facebook #}{% endverbatim %}</code></pre>
<p>That one's real — it's the comment sitting above the Open Graph block in <code>novaconium/pages/_layout/layout.twig</code>.</p>
<h2>Template inheritance &amp; includes</h2>
<p><code>{% verbatim %}{% extends %}{% endverbatim %}</code> is how every page on this site gets its <code>&lt;html&gt;</code>/<code>&lt;head&gt;</code>/nav/footer for free — a child template only fills in named <code>{% verbatim %}{% block %}{% endverbatim %}</code> slots the parent declares:</p>
<pre><code>{% verbatim %}{% extends layout %}
<pre><code class="nohighlight">{% verbatim %}{% extends layout %}
{% block title %}Blog{% endblock %}
{% block blog_content %}
...
{% endblock %}{% endverbatim %}</code></pre>
<p><code>{% verbatim %}{% include %}{% endverbatim %}</code> pulls in a whole template inline (used for <code>_layout/nav.twig</code> and <code>_layout/matomo.twig</code>), while <code>{% verbatim %}{% import %}{% endverbatim %}</code> pulls in reusable <strong>macros</strong> — parameterized snippets like the icons used throughout this page:</p>
<pre><code>{% verbatim %}{% import '_layout/icons.twig' as icons %}
<pre><code class="nohighlight">{% verbatim %}{% import '_layout/icons.twig' as icons %}
{{ icons.book() }}{% endverbatim %}</code></pre>
<p>See <a class="icon-link" href="/admin/docs/layouts">{{ icons.book() }}Layouts</a> for how <code>{% verbatim %}{% extends %}{% endverbatim %}</code> resolution walks the <code>App/</code>-over-<code>novaconium/</code> override chain.</p>