Files
novaconium/novaconium/pages/admin/docs/seo/index.twig
T
code cb64836901 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.
2026-07-14 19:00:48 +00:00

104 lines
8.7 KiB
Twig

{% extends 'admin/docs/_layout/layout.twig' %}
{% block title %}SEO{% endblock %}
{% block description %}The SEO meta tags every page gets for free, and how to override them per-page.{% endblock %}
{% block robots %}noindex, nofollow{% endblock %}
{% block docs_content %}
<h1>SEO boilerplate</h1>
<p><code>novaconium/pages/_layout/layout.twig</code> (the root layout every page extends, directly or via a nested layout) renders a full set of SEO meta tags in <code>&lt;head&gt;</code>: viewport, description, robots, keywords, canonical link, Open Graph, and Twitter Card. Each piece is a named Twig block with a sensible default, so any page can override just the piece it needs without touching the rest of <code>&lt;head&gt;</code>. Three more blocks — <code>tags</code>, <code>changefreq</code>, <code>priority</code> — are declared the same way but never rendered into the page at all; see <a href="/admin/docs/content-index">Content index</a> for what reads them.</p>
<h2>Blocks you can override</h2>
<table>
<thead>
<tr><th>Block</th><th>Default</th><th>Renders as</th></tr>
</thead>
<tbody>
<tr><td><code>title</code></td><td><code>site_name</code> config value</td><td><code>&lt;title&gt;</code>, and reused by <code>og:title</code> / <code>twitter:title</code></td></tr>
<tr><td><code>description</code></td><td>generic site description</td><td><code>&lt;meta name="description"&gt;</code>, and reused by <code>og:description</code> / <code>twitter:description</code></td></tr>
<tr><td><code>robots</code></td><td><code>index, follow</code></td><td><code>&lt;meta name="robots"&gt;</code></td></tr>
<tr><td><code>keywords</code></td><td>empty</td><td><code>&lt;meta name="keywords"&gt;</code></td></tr>
<tr><td><code>tags</code></td><td>empty</td><td>not rendered — comma-separated, harvested by <a href="/admin/docs/content-index">the content index</a> for blog tag browsing</td></tr>
<tr><td><code>changefreq</code></td><td><code>monthly</code></td><td>not rendered — harvested for <code>/sitemap.xml</code>'s <code>&lt;changefreq&gt;</code></td></tr>
<tr><td><code>priority</code></td><td><code>0.5</code></td><td>not rendered — harvested for <code>/sitemap.xml</code>'s <code>&lt;priority&gt;</code></td></tr>
<tr><td><code>canonical</code></td><td><code>{{ '{{ request_path }}' }}</code></td><td><code>&lt;link rel="canonical"&gt;</code>, and reused by <code>og:url</code></td></tr>
<tr><td><code>og_type</code></td><td><code>website</code></td><td><code>&lt;meta property="og:type"&gt;</code></td></tr>
<tr><td><code>og_title</code></td><td><code>{{ '{{ block(\'title\') }}' }}</code></td><td><code>&lt;meta property="og:title"&gt;</code></td></tr>
<tr><td><code>og_description</code></td><td><code>{{ '{{ block(\'description\') }}' }}</code></td><td><code>&lt;meta property="og:description"&gt;</code></td></tr>
<tr><td><code>og_url</code></td><td><code>{{ '{{ block(\'canonical\') }}' }}</code></td><td><code>&lt;meta property="og:url"&gt;</code></td></tr>
<tr><td><code>twitter_card</code></td><td><code>summary</code></td><td><code>&lt;meta name="twitter:card"&gt;</code></td></tr>
<tr><td><code>twitter_title</code></td><td><code>{{ '{{ block(\'title\') }}' }}</code></td><td><code>&lt;meta name="twitter:title"&gt;</code></td></tr>
<tr><td><code>twitter_description</code></td><td><code>{{ '{{ block(\'description\') }}' }}</code></td><td><code>&lt;meta name="twitter:description"&gt;</code></td></tr>
<tr><td><code>head_extra</code></td><td>empty</td><td>open-ended — anything a subtree's own layout needs in <code>&lt;head&gt;</code> that doesn't fit an existing block. <code>App/pages/blog/_layout/layout.twig</code> overrides it with the blog's RSS <code>&lt;link rel="alternate"&gt;</code>, scoped to <code>/blog/*</code> only since only that layout overrides it.</td></tr>
</tbody>
</table>
<p>Blocks that piggyback on another block (e.g. <code>og_title</code> defaulting to <code>{{ '{{ block(\'title\') }}' }}</code>) use Twig's <code>block()</code> function, not inheritance — so setting <code>title</code> alone is enough to update <code>og:title</code> and <code>twitter:title</code> too, unless the page also overrides those blocks explicitly.</p>
<h2>Overriding on a page</h2>
<p>Any <code>index.twig</code> can override any subset of these blocks, same as <code>title</code> or <code>content</code>:</p>
<pre><code class="nohighlight">{% verbatim %}{% extends layout %}
{% block title %}Pricing{% endblock %}
{% block description %}Plans and pricing for the whole team.{% endblock %}
{% block og_type %}product{% endblock %}
{% block content %}
...
{% endblock %}{% endverbatim %}</code></pre>
<p>See any post under <code>App/pages/blog/</code> (e.g. <code>App/pages/blog/twig-syntax-guide/index.twig</code>) for a working example that sets <code>og_type</code> to <code>article</code> with a hand-written <code>description</code>. If you ever need to derive a description from a longer body string, don't reach for Twig's <code>|slice</code> filter — applied to a string, it calls <code>mb_substr()</code> unconditionally with no fallback, which hard-requires the <code>mbstring</code> extension. Truncate in PHP instead, guarded with <code>function_exists('mb_substr')</code>; see the footnote on <a href="/blog/twig-syntax-guide">the Twig Syntax Guide</a> for the story of why this project cares.</p>
<h2>Copy-paste starter: every block, explicitly</h2>
<p>Every <code>App/pages/*/index.twig</code> page in this project already includes the full block below as a reference — copy it into a new page and fill in the blanks. Nothing here is required (the layout's defaults are fine on their own), but having every knob visible up front makes it obvious what's available. Don't want to copy-paste by hand? <code>php novaconium/bin/create-static-page.php &lt;path&gt;</code> scaffolds this exact template for you — see <a href="/admin/docs/getting-started">Getting started</a>.</p>
<pre><code class="nohighlight">{% verbatim %}{% extends layout %}
{% block title %}Page title{% endblock %}
{% block description %}One or two sentences describing this page.{% endblock %}
{% block robots %}index, follow{% endblock %}
{% block keywords %}{% endblock %}
{% block tags %}{% endblock %}
{% block changefreq %}monthly{% endblock %}
{% block priority %}0.5{% endblock %}
{% block canonical %}{{ request_path|default('/') }}{% endblock %}
{% block og_type %}website{% 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 content %}
&lt;article&gt;
&lt;h1&gt;Page title&lt;/h1&gt;
&lt;p&gt;...&lt;/p&gt;
&lt;/article&gt;
{% endblock %}{% endverbatim %}</code></pre>
<p>To keep a page out of search results, change only the <code>robots</code> line to <code>noindex, nofollow</code> and delete the rest — everything else can be left to the layout's defaults, same as the <code>/admin</code> pages do.</p>
<h2>Canonical URLs and <code>request_path</code></h2>
<p>The canonical link (and <code>og:url</code>) default to a <code>request_path</code> variable that <code>novaconium/src/Renderer.php</code> injects into every template's context — the request URI's path component, computed via <code>parse_url($requestUri, PHP_URL_PATH)</code>. You don't need to set this yourself; it's already correct for every route, including the 404 page. If you want an absolute canonical URL instead of a path (e.g. <code>https://example.com/about</code> rather than <code>/about</code>), override the <code>canonical</code> block per-page or add a site-wide base URL to <code>novaconium/config.php</code> and reference it in the layout.</p>
<h2>Keeping admin/internal pages out of search results</h2>
<p>Pages that shouldn't be indexed — <code>/admin</code>, <code>/admin/clear-cache</code>, every <code>/admin/docs/*</code> page, and the 404 page — override <code>robots</code> to <code>noindex, nofollow</code>. Follow the same pattern for any project-specific admin or internal tooling pages you add under <code>App/pages/</code>.</p>
<h2>Favicon</h2>
<p>The layout links <code>&lt;link rel="icon" href="/favicon.ico"&gt;</code> unconditionally — drop a <code>favicon.ico</code> into <code>public/</code> to have it picked up; there's no fallback or generation step.</p>
{% endblock %}