A Hugo theme built with TailwindCSS, Alpine.js, and Font Awesome. Intentionally barebones with practical defaults — drop it in and start writing.
Named after a late Rhodesian Ridgeback/Mastiff companion.
An open source project by Arts-Link, maintained by Ben Strawbridge.
Current release: Ryder v0.4.1 — released August 6, 2026. A dependency and documentation patch on top of v0.4.0. The v0.4.0 feature release adds a semantic colour token layer, consolidates six accent colours into one, and ships three new components plus a design-system page — one visual change for existing sites, behind a flag, see the v0.4.0 migration guide. Sites upgrading from v0.2.5 should still follow the v0.3.0 migration guide first, and review the changelog before upgrading.
- Semantic color tokens — the palette resolves through
--ryder-*custom properties, so[params.colors]can repoint brand, brand-alt, and accent without overriding a single class string. See Color Tokens. - One accent instead of six — the tag cloud's yellow border and the CTA button's fuchsia ring both move to the theme accent (rose).
params.colors.legacyAccents = truerestores them. - Three new components — a themed form field partial for the existing
ryderFormengine, atable-wrappershortcode that keeps wide tables from pushing the page sideways, and an empty state on list pages that no longer renders a blank page with a dead pager. - A design-system page — arts-link.github.io/ryder/docs/design-system/ documents the tokens, type scale, spacing, and every component, rendered by the live theme rather than screenshots of it.
- Footer taxonomy headings are links — each group heading now points at its taxonomy root page (
/tags/,/categories/), with a dotted underline so it reads as one. See Footer Taxonomy Lists.
Sites upgrading from v0.3.x should read the v0.4.0 migration guide — the accent change is the only thing that alters an existing site, and it is one config flag to undo. The reasoning behind the system is recorded in docs/design-decisions.md.
- Safe, extensible structured data — JSON-LD is built from Hugo dictionaries and serialized with
jsonify; useparams.schema.typeandhead/schema-extra.htmlto extend it without replacing Ryder's built-in schema. - One CSS build workflow — Ryder now ships a reusable
tailwind.preset.js;hugo serverandhugo --minifycompile Tailwind through PostCSS, so the oldbuild-tw,watch-tw, anddeploy-twscripts are gone. - Stricter Content Security Policy — the PostHog bootstrap is a fingerprinted external asset,
script-srcno longer gains'unsafe-inline', and sites can allow individual inline scripts withparams.csp.scriptSrcHashes. - Consumer-owned build configuration — sites must keep their own
[build]settings for Hugo stats and cachebusters, and their own[outputs]entry to generate/llms.txt; neither section is inherited from the theme.
New installations can continue with Quick Start. Sites upgrading from v0.2.5 should use the migration guide for the four breaking changes.
If you're in a hurry, here's the minimal setup:
hugo new site <your-site>
cd <your-site>
git init
# Install theme
git submodule add https://github.com/arts-link/ryder.git themes/ryder
# Copy config and npm setup from exampleSite
cp -r themes/ryder/exampleSite/config/ ./config
cp themes/ryder/exampleSite/package.json .
cp themes/ryder/exampleSite/package-lock.json .
cp themes/ryder/exampleSite/*.config.js .
npm ci
# You must delete the dev-only theme path override before building your site
sed -i.bak '/^themesDir *=/d' config/_default/hugo.toml
# Update baseURL in config/_default/hugo.toml to your own URL
# Create a home page and start the server
hugo new content _index.md
hugo server -DThe copied config is a starter configuration from the theme demo. Before building your site, update site-facing values in config/_default/hugo.toml such as baseURL, title, homePageFeedHeader, homePageFeatureHeader, and the logo text settings so they match your site.
- Hugo 0.146+ (extended recommended; required if any Open Graph / processed image — including
og_image_default— is a WebP) - Node.js / npm
Theme development and tests require Node.js ^22.22.2, ^24.15.0, or 26+.
- Dark mode — toggle built in, or follow system preference
- Two-level mobile nav — Alpine-powered hamburger menu with configurable submenu trigger behavior
- Card layouts — multiple variants, configurable per section or globally
- Featured grid — promote any page to the homepage featured grid via front matter
- Shortcodes — alerts, maps, recipe schema, media embeds, CTAs, photo gallery, and more
- Image galleries — page-bundle gallery layout or shortcode-driven gallery with lightbox
- Structured data — WebPage, configurable site entity, BlogPosting, breadcrumbs, Recipe JSON-LD, and a
head/schema-extra.htmlextension hook - Privacy-friendly analytics — pluggable Plausible or PostHog integration
- CSP-safe Alpine — runs without
'unsafe-eval', withryderTrack/ryderFormcomponents, a custom-JS hook, and a dev-only linter (see CSP-Safe Alpine) - SEO & GEO built-in — full JSON-LD structured data, Open Graph, Twitter Cards, and dynamic OG image generation on every page (see SEO & GEO)
- Custom RSS feed — styled XSLT browser-readable feed
- Social links — footer social icons via
data/social.json - i18n — partial translations for English, German, French
- Template overrides — header, footer, menu, and card partials can be swapped with your own suffix-based variants
- Hidden home layout — full-bleed cover image with minimal content for landing pages
git submodule add https://github.com/arts-link/ryder.git themes/rydercp -r themes/ryder/exampleSite/config/ ./config
cp themes/ryder/exampleSite/package.json .
cp themes/ryder/exampleSite/package-lock.json .
cp themes/ryder/exampleSite/*.config.js .
npm ciRequired: Delete the line themesDir = "../.." or themesDir = "../../" from config/_default/hugo.toml before you build. It exists only for theme development inside this repository and will break a normal site installation.
The copied config is a starter configuration from the theme demo. Update site-facing values in config/_default/hugo.toml such as baseURL, title, homePageFeedHeader, homePageFeatureHeader, and the logo text settings so they match your site.
Full example in exampleSite/config/_default/hugo.toml.
[params]
darkMode = "system" # "system" (default), "toggle", or "off" — see Dark Mode below
showHomeFeed = true # Paginated feed on home page (page-overridable via .Param, e.g. in the home page's own front matter)
showDate = true
showAuthor = true
showBreadCrumbs = true
showShareButtons = false # Social share buttons on single pages
showCardLinkOverlay = false # Whole-card click target
showSummaryMeta = true # Show meta on card summaries
showReadOn = false # "Read on" link on cards
loadLeaflet = false # Load Leaflet.js for map shortcodes
navbar_fixed = false # Sticky header
homePageFeatureHeader = "Features" # Label for featured grid
homePageFeedHeader = "Latest" # Label for paginated feed
logo_firstWord = "your"
logo_lastWord = "site"
logo_tagline = "FOR HUGO WEBSITES"
og_image_default = "images/og-default.webp"
repository = "https://github.com/you/your-site" # Enables footer GitHub links
excludedSections = ["fineprint"]
excludedCategories = ["catalog"]
excludedtags = ["sample", "test"]
[params.twClasses]
body = "" # <body> classes; default "bg-neutral-100 text-neutral-900 font-titillium"
bodyDark = "" # <body> dark: classes; only emitted when darkMode != "off"
headerBackgroundFrameOuter = "bg-gradient-to-r from-slate-900 to-slate-700 text-neutral-100"
headerBackgroundFrameInner = "bg-cover h-[300px]"
footerBackground = "" # Falls back to headerBackgroundFrameOuter
[params.author]
name = "Your Name"
email = "you@example.com"[params]
darkMode = "system" # "system" (default), "toggle", or "off""system"(default) — the page follows the visitor's OS/browser preference. No toggle is shown."toggle"— same as"system"on first load, plus a footer toggle that lets visitors switch and persists their choice tolocalStorage."off"— dark mode is disabled entirely: the theme-boot script that applies thedarkclass is not loaded, no toggle is shown, and the<body>element is rendered without anydark:Tailwind classes at all (not just an unuseddarkclass — the classes themselves are omitted).
showDarkToggle = true is a legacy alias for darkMode = "toggle", kept for existing sites. If darkMode is not set explicitly:
| Site config | Resolved darkMode |
|---|---|
| nothing set | "system" |
showDarkToggle = true |
"toggle" |
Prefer darkMode in new configuration; showDarkToggle is only read as a fallback when darkMode is absent.
Ryder keeps the page frame stable and lets you swap selected partials by suffix instead of editing the theme's base layout.
For example, if you set:
[params]
headerType = "-custom"
footerType = "-custom"then Hugo will look for:
layouts/partials/header-custom.htmllayouts/partials/footer-custom.html
in your site first, before falling back to the theme.
The same pattern applies to other overridable partials:
[params]
menuType = "-custom"
listCardType = "-custom"
homeListCardType = "-custom"
homeFeatureListCardType = "-custom"That resolves to partials such as:
layouts/partials/menu-custom.htmllayouts/partials/card-custom.html
Use this when you want to replace a whole component cleanly without editing baseof.html or forking the theme's default partial names.
Reach for a -suffix variant (above) only when you need a genuinely different
structure. For a cosmetic change — different background, different nav
treatment — prefer a .Param the base partial already reads, so you get the
one bug fix or feature added to header.html in the future for free instead
of carrying it into a forked copy forever.
header.html already reads page-overridable twClasses.headerBackgroundFrameOuter,
twClasses.headerBackgroundFrameInner, and twClasses.headerBackgroundImage
for exactly this. It also resolves a nav skin — navClass (or the
twClasses.nav convention) — and passes it into the menu partial, so a single
page or section can restyle just the <nav> without forking header.html
into a new headerType variant just to change classes:
+++
title = "A page with a different nav treatment"
navClass = "main-menu-nav bg-fuchsia-900/40 rounded-full px-2"
+++or site-wide:
[params.twClasses]
nav = "main-menu-nav bg-fuchsia-900/40 rounded-full px-2"(Keep the base main-menu-nav class if you only mean to add to it, not
replace it — main.css's nav styling lives on that class.) This is the pattern to
reach for before writing a new header-*.html variant whose only difference
from header.html is a handful of classes.
_default/list.html always paginates .Pages into a card grid. For a section
that's really a single data-driven page with no children (an "about" or
"contact" page sourced from data/*.json, say), that pagination shell is dead
weight and used to force a full fork of list.html. Set layout in the
section's _index.md front matter instead:
+++
title = "About"
layout = "list-plain"
+++_default/list-plain.html renders title + .Content only — no pagination, no
card grid. This uses Hugo's own layout front-matter field (not a theme
param) because, unlike headerType/menuType above, Hugo has no other
mechanism for choosing between two top-level list templates.
Pair it with partials/utils/data-items.html, a returning partial for the
"does this data file have anything in it" check that's easy to end up
hand-rolling at every call site:
{{ $items := partial "utils/data-items.html" "press" }}
{{ if gt (len $items) 0 }}
...
{{ end }}
It resolves .Site.Data.<name>.items, defaulting to an empty slice when the
data file or its items key is missing, so callers only need to check
len(). See exampleSite/content/press/_index.md
for both in use together, and the conditional menu entries below,
which reuse the same partial.
[[params.alphaAlert]]
alertType = "info" # info | success | warning | danger
alertTitle = "Heads up"
alertMessage = "Something worth knowing."
alertIconClass = "fa-solid fa-circle-info"
dismissable = true
weight = 1[params.footer]
tagCloud = true
[[params.footer.taxonomies]]
name = "tags"
title = "Top Tags"
minCount = 2
[[params.footer.taxonomies]]
name = "categories"
title = "Categories"
minCount = 1By default the footer shows no taxonomy lists — params.footer.taxonomies
is opt-in. minCount hides terms with fewer than that many pages, so a footer
cloud doesn't fill with one-off tags.
Each group's heading links to that taxonomy's own root page (/tags/,
/categories/) and carries a dotted underline to say so. The href resolves
through site.GetPage, so if a site disables the taxonomy kind or renames its
path the heading falls back to plain text rather than linking to a 404.
Configured via data/social.json (not params), in either of two shapes.
Structured — an icon is a Font Awesome class string:
{
"main": [
{ "title": "GitHub", "name": "github", "icon": "fab fa-github", "link": "https://github.com/you", "weight": 10 }
]
}Flat name → URL map — what Decap CMS emits, and what used to render nothing at all:
{
"instagram": "https://instagram.com/you",
"tiktok": "https://tiktok.com/@you"
}Entries in the flat shape have no icon field, so one is resolved by
platform name instead. Ryder ships inline SVGs for Instagram, TikTok, Apple
Music, Tidal, and Spotify — matched case-insensitively, ignoring spaces,
dashes, and underscores ("Apple Music", "apple-music", and
"apple_music" all resolve the same icon) — rather than widening the
tree-shaken Font Awesome brand set that tests/unit/faIcons.test.js enforces
against unused imports. Anything else falls back to a generic external-link
icon. The same fallback also applies to structured entries that omit icon,
so you can mix both within main (see
exampleSite/data/social.json).
Select a provider explicitly in your site params:
[params]
analytics_provider = "plausible" # or "posthog"[params]
plausible_domain = "yourdomain.com"
plausible_advanced = truePreferred: set it directly in params. No Hugo security configuration required — this is the simplest path and the one to reach for first:
[params]
analytics_provider = "posthog"
posthog_key = "phc_yourprojectkey"
posthog_host = "https://t.example.com"
posthog_ui_host = "https://us.posthog.com"
posthog_person_profiles = "identified_only"Alternative: environment variables. Useful for keeping the key out of version control (e.g. injecting it at CI/deploy time). Params always take precedence when both are set.
PUBLIC_POSTHOG_KEYPUBLIC_POSTHOG_HOSTPUBLIC_POSTHOG_UI_HOST
This path requires one extra step the params path doesn't: Hugo's getenv only reads environment variables matching ^HUGO_ or ^CI$ by default. Any PUBLIC_POSTHOG_* variable is silently read as empty — no error, no warning — unless you explicitly widen the allowlist:
[security]
[security.funcs]
getenv = ['^HUGO_', '^CI$', '^PUBLIC_']Without this block, PostHog renders nothing at all, with no signal as to why — the same silent-empty result as simply never setting the variables. If analytics_provider = "posthog" is set and no key can be found from either source, the build now emits a warning naming both possible causes.
Ryder emits a Content-Security-Policy via <meta http-equiv> with secure defaults, extendable per-directive under [params.csp]:
[params.csp]
# disabled = true # opt out entirely (e.g. your host sets the header instead)
# imgSrc = "https://cdn.example.com"
# scriptSrc = "https://cdn.example.com"
# styleSrc = "https://cdn.example.com"
# connectSrc = "https://api.example.com"
# fontSrc = "https://cdn.example.com"
frameSrc = "https://your-embed-host.example.com" # any additional iframe hosts, verbatim
embeds = ["youtube", "vimeo", "soundcloud", "spotify", "umap"]
# scriptSrcHashes = ["sha256-…"] # SHA-256 of each of your inline <script>s
# extraDirectives = "worker-src 'none';"Every script Ryder emits — main.js, themeBoot.js, the dev linter, and the
PostHog bootstrap — is a real asset served from 'self' with a Subresource
Integrity hash, so script-src 'self' covers all of them. 'unsafe-inline'
is never added to script-src in production, and turning on analytics no
longer adds it for you.
Changed in v0.3.0 — this can break an existing site silently. Before v0.3.0, enabling PostHog appended
'unsafe-inline'toscript-srcfor the whole site, becauseposthog.htmlinlined its bootstrap snippet. Any inline<script>of your own was being permitted by that side effect. It no longer is. CSP violations do not fail the build — they fail in the visitor's browser — so grep your templates for<script>without asrcbefore upgrading, and verify with a production build and a browser console showing zero violations rather than by reading the config.
If your site has an inline script of its own, you have two options.
Preferred — list its hash. The policy ships as a <meta http-equiv> tag,
and a meta-delivered CSP cannot carry a nonce (nonces must be generated per
response, which a static site never gets to do). Hashes are the alternative:
[params.csp]
scriptSrcHashes = ["sha256-Ki9lqrTGVaMOtvJBiJhb3D2Cu5g0S4XLNJfDmxvGvBM="]Load the page and read the hash out of the CSP violation message in the browser console — it names the exact value the blocked script needs. Quotes are added for you if you leave them off. This is the same mechanism Ryder already used for Plausible's advanced-mode inline scripts.
Escape hatch — turn script CSP off. Still supported, but now something you say deliberately rather than something analytics does to you:
[params.csp]
scriptSrc = "'unsafe-inline'"A third option is usually better than both: move the code into
assets/js/extended.js, where it
is bundled into main.js and needs no CSP allowance at all.
Note that style-src does keep 'unsafe-inline', and that is deliberate:
Alpine's x-show sets display:none as an inline style, so removing it would
break every collapsible element in the theme. head/csp.html documents both
decisions inline.
By default default-src 'self' blocks every iframe, including the theme's own soundcloud and openstreetmap shortcodes. frame-src is assembled from three sources and folded together (deduped), and omitted entirely when none apply:
- Auto-detected hosts. The
soundcloudandopenstreetmapshortcodes register their own iframe host automatically whenever they're used on a page — no config needed. embedspreset. A list of known embed names —youtube,vimeo,soundcloud,spotify,umap— mapped to their hosts. Use this for embeds the theme can't auto-detect, such as Hugo's built-inyoutubeandvimeoshortcodes.frameSrc. Any additional hosts, added verbatim.
By default the logo renders as a two-word text mark built from logo_firstWord and logo_lastWord. If neither is set the first two words of title in your config are used.
Text logo
[params]
logo_firstWord = "my" # First word (sky-blue)
logo_lastWord = "site" # Second word (lime-green)
logo_tagline = "FOR HUGO WEBSITES" # Small tagline below the words
logo_fontClass = "font-titillium" # Optional custom Tailwind font class
logo_collapse = true # Collapse to initials on small screensImage logo
Place your logo file anywhere under static/ or assets/ and point logo_png at it. The image is scaled to a maximum height of 4 rem; width is automatic.
[params]
logo_png = "/images/logo.png" # Path relative to your site rootWhen logo_png is set the text words are hidden. logo_tagline is still shown below the image if it is also set.
Steps:
- Copy your file (
.png,.svg,.webp, etc.) intostatic/images/in your site. - Set
logo_png = "/images/logo.png"in[params]insideconfig/_default/hugo.toml.
logo_png is read via .Param, so it is page-overridable (front matter wins
over site config) — this is the authoritative contract; a .Site.Params.logo_png-only
reading is not supported.
Wrapper chrome. The grey rounded box (background, hover state, padding)
around the logo makes sense as a frame for the generated text mark, but is
usually unwanted around a real logo image. It is dropped automatically once
logo_png is set:
[params]
logo_png = "/images/logo.png" # wrapper chrome is dropped automaticallyTo keep some wrapper styling (with either logo type), set logo_wrapperClass
explicitly — it always wins over the default:
[params]
logo_wrapperClass = "bg-white/80 rounded-lg p-2"head/favicon.html used to pin a single hardcoded /favicon.ico?v=4 with no
way to change the path, cache-busting version, or add an apple-touch-icon /
web manifest link short of overriding the whole partial. Configure it under
[params.favicon]:
[params.favicon]
ico = "/favicon.ico" # default; the theme ships this file
version = "4" # cache-busting ?v= suffix; set "" to omit
svg = "/images/favicon.svg" # default; the theme ships this file
appleTouchIcon = "/images/apple-touch-icon.png" # default; the theme ships this file
webmanifest = "" # no default; the theme ships no manifest fileEvery key defaults to the file the theme already ships (except
webmanifest, which has none) — set a key to "" explicitly to omit that
tag entirely, or to a different path to replace it.
Site-level only, not page-overridable. head.html loads this partial via
partialCached with no explicit cache key, so it renders once and that
single render is reused for every page — a per-page override would silently
leak onto every other page too. Read site.Params directly, in
config/_default/hugo.toml (or equivalent), not front matter.
Titillium Web is hardcoded in four places in the theme (tracked as
issue #3): head/fonts.html's
Google Fonts URL, baseof.html's font-titillium body class,
tailwind.config.js's fontFamily.titillium key, and a raw font-family in
assets/css/main.css. Two of those four are covered by params.fonts:
[params.fonts]
family = "Titillium Web" # default; sets --ryder-font-family and the display name below
googleFontsFamily = "Titillium+Web:wght@400;600;700" # default; the family= query value Google Fonts expects
disableGoogleFonts = false # true if you self-host fonts, or don't want this stylesheet at allfamily sets a --ryder-font-family CSS custom property that
assets/css/main.css's .resp-sharing-button rule now reads (falling back
to Titillium Web if unset), rather than hardcoding the font name directly.
Set only family (without googleFontsFamily) to point at a font you load
some other way — self-hosted, a different provider — while skipping this
partial's Google Fonts request via disableGoogleFonts.
Site-level only, same partialCached constraint as Favicon
above.
The other two hardcoded sites are covered elsewhere, not here: the body
class is already overridable via [params.twClasses] body (added
alongside the .site-shell wrapper), and tailwind.config.js's
fontFamily.titillium key will move into the Tailwind preset. This does not
close issue #3 by itself, since three of the four sites are theme files a
consumer must not edit directly — see the issue for the full picture.
Ryder supports two-level menus on desktop and mobile. Parent items with children can use one of two submenu trigger modes:
caret(default): the parent label remains a link, and only the caret toggles the submenubutton: the whole parent row toggles the submenu, and the parent landing page is not linked
Example:
[[menus.main]]
name = "Docs"
pageRef = "/docs"
weight = 20
[menus.main.params]
submenuTrigger = "button"See the example docs page for a complete menu setup:
exampleSite/content/docs/menus.md
Hide a menu entry unless a data/*.json file (shaped {"items": [...]}) has
content, via hideIfEmptyData under the entry's own [menus.main.params]:
[[menus.main]]
name = "Press"
pageRef = "/press"
weight = 40
[menus.main.params]
hideIfEmptyData = "press" # renders only if data/press.json's items is non-emptyThis replaces forking the entire nav to append a hand-rolled
{{ if gt (len (.Site.Data.press.items | default slice)) 0 }} check — see
exampleSite/config/_default/hugo.toml, where "Press" (backed by
data/press.json) renders and "Merch" (backed by a data/merch.json that
doesn't exist) does not.
Enables GitHub commit/history/blame links in the footer. Has a 40–50% build time cost on large sites — disabled by default.
# config/production/hugo.toml
enableGitInfo = true| Shortcode | Description |
|---|---|
alert-wrapper |
Alert boxes (info/success/warning/danger), usable as shortcode or partial |
cta-button |
Call-to-action button |
leaflet |
Interactive map from coordinates (self-hosted Leaflet) |
openstreetmap |
Embed a pre-built uMap |
lat-long-box |
Display coordinates in a formatted box |
recipe-ingredients-list |
Render recipe ingredients from front matter |
recipe-howto-steps-list |
Render recipe steps from front matter |
picture |
Responsive image with lazy loading |
soundcloud |
SoundCloud embed |
youtube-embed |
YouTube embed that auto-registers its CSP host (named distinctly from Hugo's built-in youtube) |
spotify-embed |
Spotify track/album/playlist/artist/episode/show embed, auto-registers its CSP host |
video-lightbox |
Clickable thumbnail that opens a modal with a YouTube or Vimeo embed (beside imageGallery's image-only lightbox) |
amazon-associate-link |
Affiliate link with disclosure |
font-awesome |
Inline Font Awesome icon |
highlight-github |
GitHub-styled syntax highlight block |
table-wrapper |
Wraps a Markdown table in a themed shell that scrolls horizontally instead of widening the page |
highlight-github reads the file through the GitHub API at build time. When
api.github.com is unreachable — a sandboxed CI runner, or an environment that
routes GitHub traffic through a proxy serving only git operations — the
shortcode logs a warning and degrades to a plain link to the file on GitHub
rather than failing the build. The link is rendered even when showlink=false,
since it is the only remaining output.
To treat an unreachable API as a hard build failure instead, set:
[params]
highlightGithubStrict = trueThis is off by default deliberately. Gating the failure on
hugo.Environment would not work: a plain hugo build already reports the
production environment, so the strict path would fire in exactly the
sandboxed CI builds this degradation exists to keep working.
Set recipe = true in front matter to enable Schema.org/Recipe JSON-LD structured data. Ingredients and steps live entirely in front matter:
recipe = true
recipeCuisine = "Breakfast"
prepTime = "PT10M"
cookTime = "PT30M"
totalTime = "PT40M"
recipeYield = "4 servings"
calories = 350
recipeIngredients = [
"2 cups flour",
"1 cup sugar",
"**Wet ingredients", # prefix ** for a subheading
"2 eggs",
]
[[recipeInstructions]]
name = "mix"
text = "Combine dry ingredients."
[[recipeInstructions]]
name = "bake"
text = "Bake at 350°F for 30 minutes."
image = "step-bake.webp" # optionalThen in content:
{{</* recipe-ingredients-list */>}}
{{</* recipe-howto-steps-list */>}}
Requires loadLeaflet = true in params.
{{</* leaflet id="map1" lat="40.71" lon="-74.00" zoom="13" markerPopup="New York" */>}}
{{</* lat-long-box latitude="40.71" longitude="-74.00" */>}}
{{</* openstreetmap mapName="your-map-name-123456" */>}}
Ryder ships with a complete search and AI optimisation stack — no plugins, no extra configuration required. Every page gets the right metadata automatically.
| Output | What It Does |
|---|---|
<meta name="description"> |
Page snippet for search results — from description front matter, then summary, then site description |
| Open Graph tags | Social link previews (Facebook, LinkedIn, Slack, Discord) |
| Twitter / X Cards | summary_large_image when a featured image is present, summary otherwise |
JSON-LD BlogPosting |
Article authorship, dates, and keywords for Google rich results and AI crawlers |
JSON-LD WebPage + site entity |
Homepage entity signals — entity type set by params.schema.type |
JSON-LD BreadcrumbList |
Section and category navigation trails for rich-result breadcrumbs |
JSON-LD Recipe |
Full recipe structured data (ingredients, steps, nutrition) when recipe = true |
| Dynamic OG image | Auto-generated Open Graph image with title text when no page image exists |
Ryder defines an LLMSTxt output format and ships the
_default/home.llmstxt.txt template that renders it: a plain-text index of
your site for AI crawlers, served at /llms.txt.
The format definition is inherited from the theme. The [outputs] block is
not. Add this to your own site config, or no llms.txt is ever written:
[outputs]
home = ["HTML", "RSS", "LLMSTxt"]You do not need to redeclare [outputFormats.LLMSTxt] — Hugo does merge a
theme's outputFormats into the site's, so naming "LLMSTxt" above is enough.
It is specifically outputs that does not propagate, the same way build
does not (see Build configuration).
Verified against a scratch consumer site whose entire config was baseURL,
title, and theme = "ryder":
| Consumer config | hugo config reports |
/llms.txt |
|---|---|---|
no [outputs] |
home = ['html', 'rss'] — Hugo's stock default |
not written |
home = ["HTML", "RSS", "LLMSTxt"] |
as written | written |
In both runs [outputformats.llmstxt] was present in the merged config, which
is what makes the one-line block above sufficient.
If you are reading older guidance that calls this block redundant because "theme config merges into the site's" — it isn't, and it doesn't. Hugo merges only a subset of root config sections from a theme.
Generative Engine Optimization (GEO) is the practice of structuring content so AI-powered search tools (ChatGPT, Perplexity, Google AI Overviews, Gemini) can understand, cite, and accurately attribute it. Ryder's JSON-LD blocks give every post clear authorship, semantic type information, and machine-readable facts — exactly what these systems need to surface your content confidently.
Most SEO metadata is automatic. A few optional settings unlock additional features:
[params]
og_image_default = "images/og-default.webp" # Base image for generated OG cards; assets/-relative ONLY (see note below)
[params.author]
name = "Your Name"
email = "you@example.com" # Flows into author/publisher schema
[params.social]
twitter = "yourtwitterhandle" # Adds twitter:site to every page
[params.ogImageText]
fontColor = "#085624" # Title text colour on generated OG images
x = 50 # Text x position (px from left)
y = 430 # Text y position (px from top)
[params.schema]
type = "Organization" # Site-wide JSON-LD entity on the home pageEvery JSON-LD block Ryder emits is built as a Hugo dict and serialised with
jsonify. That is a deliberate constraint, not a style preference: JSON
hand-written as template text fails silently. A stray comment, an unset
optional value leaving a dangling "key": ,, or a trailing comma produces a
block no consumer can parse, and nothing in the Hugo build reports it. If you
extend Ryder's structured data, build a dict — never write JSON punctuation
that reaches the output.
The home page carries one site-wide entity alongside its WebPage block.
It defaults to Organization. Set any schema.org type instead:
[params.schema]
type = "MusicGroup" # or Person, LocalBusiness, NGO, …Person receives an image rather than a logo, since schema.org gives
logo to Organization and its subtypes only. Otherwise the entity is built
from title, params.author.email, and params.logo_png.
To emit additional types — MusicEvent, Product, FAQPage — do not
override layouts/partials/head/schema.html. Overriding it silently drops
WebPage, BlogPosting, the site entity, and both BreadcrumbList blocks,
with no build error and correct-looking HTML. That trap is the reason this hook
exists.
Instead, create:
layouts/partials/head/schema-extra.html
It is an empty no-op in the theme, called from head-seo.html immediately
after head/schema.html, and it shadows cleanly through Hugo's union
filesystem — the same pattern as extend_head.html. It receives the page as
its context.
{{ if .IsHome }}
{{ $band := dict
"@context" "https://schema.org"
"@type" "MusicGroup"
"name" site.Title
"url" site.BaseURL
"genre" (slice "indie" "shoegaze")
}}
<script type="application/ld+json">{{ $band | jsonify | safeJS }}</script>
{{ end }}
layouts/partials/extend_head.html is an empty partial called as the last line
of head.html. Shadow it to add verification meta tags, a third-party
<script src>, preload hints, or any other head content, without touching
head.html:
<meta name="google-site-verification" content="…">
<link rel="preconnect" href="https://cdn.example.com">
It runs after the CSP meta tag is emitted, so anything you add here still has
to satisfy the policy — see Content Security Policy.
Prefer head/schema-extra.html for structured data specifically, so the two
concerns stay separable.
| Front matter | Where It Appears |
|---|---|
description |
<meta name="description">, og:description, BlogPosting.description |
tags |
article:tag OG properties, BlogPosting.keywords |
date |
article:published_time, BlogPosting.datePublished |
lastmod |
article:modified_time, BlogPosting.dateModified |
categories |
Second BreadcrumbList from taxonomy path |
recipe = true |
Enables full Recipe JSON-LD block |
The OG image resolver checks, in order:
og_imagefront matter — a per-page escape hatch. Point it at a resource (a page-bundle image, or a path underassets/; a leading slash is tolerated and stripped) and it's used as-is for that page, no generation.- Page-bundle resources — a
feature*,cover*, orthumbnail*image already in the page's bundle. - Generated card — if neither of the above applies, Ryder generates an
Open Graph image at build time by overlaying the page title and site name
onto your
og_image_defaultbase image. The result is a static.webpbaked into your build — no server-side rendering.
+++
title = "A specific page"
og_image = "my-hand-designed-card.png" # page-bundle resource, or assets/-relative
+++If you already have front matter named og_image for something else, note
that it is now consumed by this resolver as of this widening.
og_image_default must live under assets/, not static/. It is resolved with resources.Get, which only sees files under assets/ — unlike logo_png (see Logo), which works from either static/ or assets/. A leading slash is tolerated and stripped, but the file itself must be under assets/; a missing or static/-only file now fails the build with a named error instead of a nil-pointer panic.
See the full feature breakdown and tips in the SEO & GEO docs post.
Add homeFeature = true to any page's front matter to include it in the homepage featured grid. Use homeFeatureWide = true for a full-width card. Optionally set homeFeatureIcon (Font Awesome class) and homeFeatureTitle.
homeFeature = true
homeFeatureIcon = "fa-solid fa-star"
homeFeatureTitle = "Custom card title"Header, footer, menu, and card partials support variant suffixes. Set in hugo.toml or per-page front matter:
headerType = "-custom" # loads header-custom.html
footerType = "-custom" # loads footer-custom.html
menuType = "-custom" # loads menu-custom.html
listCardType = "-super-simple"hugo server and hugo --minify are the whole workflow. Tailwind compiles
inside the Hugo build: head/css.html pipes assets/css/main.css through
css.PostCSS, which runs your postcss.config.js, which runs Tailwind. There
is no watcher to start in a second terminal and no artifact to commit.
What that requires at your project root (not in themes/ryder/ — Hugo
invokes PostCSS from your project root, so that is the only node_modules it
consults):
npm i -D "tailwindcss@^3.4.0" postcss postcss-cli autoprefixer @tailwindcss/typographyPin Tailwind to v3. Ryder is a Tailwind v3 theme: tailwind.preset.js uses
v3 config syntax, and v4 cannot be used as a PostCSS plugin directly. A bare
npm i -D tailwindcss installs v4 today and the build fails with "It looks
like you're trying to use tailwindcss directly as a PostCSS plugin".
plus a postcss.config.js:
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};and a tailwind.config.js requiring the theme's preset (see below). With those
in place:
hugo server # dev, with live CSS rebuilds
hugo --minify # productionRemoved in v0.3.0:
npm run build-tw,watch-tw, anddeploy-tw. They ran the Tailwind CLI to writethemes/ryder/assets/css/style.css, a file no template ever read, and earlier versions of this README presented them as the build workflow — which pushed consuming sites into a two-terminal dev loop that was never required. If any CI, Vercel, or Netlify build command runsnpm run build-tw && hugo --minify, drop the first half; it will now fail on a missing script. Confirmhugo --minifyalone works before you upgrade.
Ryder ships its design tokens as a preset, tailwind.preset.js. Your site's
tailwind.config.js requires it and supplies its own content globs:
// tailwind.config.js — at your project root
module.exports = {
presets: [require('./themes/ryder/tailwind.preset.js')],
content: [
'./themes/ryder/layouts/**/*.html',
'./layouts/**/*.html',
'./content/**/*.md',
'./hugo_stats.json',
],
};The preset carries theme, darkMode, and plugins — the theme's colours,
fontFamily, custom screens (xs, 3xl), background images, darkMode: 'class', and the typography plugin. Add your own tokens under theme.extend
and they merge on top; set a key under theme directly and it replaces the
preset's.
The preset deliberately carries no content. Content globs resolve
relative to wherever Tailwind is invoked, which for your site is your project
root — a path the theme cannot know. This is also why you must not
require('./themes/ryder/tailwind.config.js'): that file's globs are written
for this repository's directory layout, so from your project root
./exampleSite/... does not exist and ./themes/ryder/layouts/... resolves to
themes/ryder/themes/ryder/layouts/.... Tailwind finds no classes and emits an
almost-empty stylesheet, with no error.
Upgrading from v0.2.x? If your
tailwind.config.jsrequires the theme'stailwind.config.js, switch to thepresets: [...]form above. This one fails loudly at build time, so you will not miss it.
Note the fontFamily.titillium entry resolves through
var(--ryder-font-family, "Titillium Web"), so the font-titillium class that
baseof.html puts on <body> follows [params.fonts] rather than
contradicting it. If you replace fontFamily wholesale in your own config,
keep that indirection or set twClasses.body to a class of your own.
New in v0.4.0. Ryder's colors resolve through CSS custom properties, so you can repoint the palette from config instead of overriding class strings one element at a time:
[params.colors]
brand = "12 74 110" # logo word 1, nav links, article meta icons, blockquote
brand-alt = "54 83 20" # logo word 2, active nav entry
accent = "217 70 239" # aside icons, tag chips, tag cloudchrome-from and chrome-to are also declared, but nothing in the theme
reads them — the header and footer gradients come through
twClasses.headerBackgroundFrameOuter, which deliberately stays a literal
Tailwind string. They exist so you can use from-ryder-chrome-from /
to-ryder-chrome-to in class strings of your own; setting them will not retint
your header.
Values are RGB channel triplets, not hex — "244 63 94", not "#f43f5e".
This is load-bearing, not stylistic: a hex value inside var() works for
text-, bg-, and border-, then silently produces nothing for every
opacity modifier, and Ryder uses those throughout (border-ryder-accent-300/80,
dark:bg-ryder-accent-950/40, the share-button fills at /88). Channels
interpolate with Tailwind's <alpha-value> and keep them working. A value in
any other format is ignored, with a build warning naming the key.
Each of brand, brand-alt, and accent also carries a full 50–950 ramp,
because the theme uses more than one shade of each — the nav alone spans
brand-100 through brand-800. Setting the bare token moves only that
family's canonical step, and which step that is differs per family: brand
aliases brand-800, brand-alt aliases brand-alt-800, accent aliases
accent-500.
This catches people out. The default CTA button uses accent-600 / accent-500
/ accent-300 for border, hover, and focus ring — so setting accent alone
moves the hover and leaves the border and ring rose. Set every step a component
uses; rg 'ryder-accent-' layouts assets/css lists them. Anything left unset
keeps the theme default, so a partial override is safe, just incomplete.
The preset exposes all of them as ordinary Tailwind colors, usable anywhere you
write a class — including in twClasses:
text-ryder-brand bg-ryder-accent-50 border-ryder-brand-alt-800
from-ryder-chrome-from to-ryder-chrome-to ring-ryder-accent-300/80
Ryder's own shipped twClasses defaults deliberately stay on plain Tailwind
classes. They get copied into user configs, and a default that references the
token layer while the copy doesn't leaves you with half a palette. Surfaces
(neutral-*), structural greys, the alert color triples, and the
Amazon/Spotify button fills are not tokenized either — see
docs/design-decisions.md for the reasoning.
Full reference, including which element each token drives:
/docs/css-overrides/#color-tokens on the demo site.
Add this [build] block to your own site config — Hugo merges only a subset of root config sections from themes, and build is not one of them, so you do not inherit it from Ryder:
[build]
writeStats = true
[[build.cachebusters]]
source = "(postcss|tailwind)\\.config\\.js"
target = "css"
[[build.cachebusters]]
source = "assets/.*\\.(js|ts|jsx|tsx)"
target = "js"
[[build.cachebusters]]
source = "assets/.*\\.(css|scss|sass)"
target = "css"writeStats produces hugo_stats.json at your project root, which tailwind.config.js globs for class discovery; the cachebusters make hugo server pick up CSS/JS rebuilds. Without the block, Tailwind silently falls back to the layouts/**/*.html globs — most classes are still found, so nothing appears broken, but any class assembled dynamically in a template is purged from the CSS. Add hugo_stats.json to your .gitignore. Hugo rewrites it on every build, so tracking it means every hugo server run dirties your working tree and blocks the next git pull. This theme tracked it until v0.3.0 and untracked it for exactly that reason.
The tradeoff is small and worth stating precisely: on a cold clone with no prior build, the first CSS compile is missing any class that exists only in the stats file — classes assembled dynamically in a template rather than written literally, such as resp-sharing-button--small built from [params.shareButtons] size. In this theme's own exampleSite that is 3 classes out of 726. Every later build has them. If you build release artifacts from a fresh clone in CI, build twice, or commit the file deliberately and accept the pull friction.
v0.2.4 note. That release moved this block into the theme's own config on the assumption that consumers would inherit it. They don't. If you upgraded to v0.2.4 and deleted your
[build]block, put it back.
Read this before writing any x- or @ attribute. Ryder bundles
@alpinejs/csp, not standard Alpine, so the
theme works under a Content Security Policy without 'unsafe-eval'. The tradeoff
is that inline Alpine expressions are not JavaScript. They are parsed by a
small evaluator that resolves every identifier against the component's own
Alpine scope.
Two consequences, and they have caused real outages in production sites built on this theme — silently, because a broken directive renders normally and the handler simply never fires:
1. Inline expressions cannot reach globals. posthog, window, document,
fetch, gtag — none of them are in Alpine's scope, so none of them resolve.
<!-- BROKEN: `posthog` is a global, not component state -->
<a @click="posthog.capture('signup_click')">Sign up</a>
<!-- BROKEN: `window` is no more reachable than `posthog` -->
<button @click="window.myHelper()">Go</button>2. Arrow functions are a parse error. @click="$nextTick(() => x)" never
runs.
What does work is referencing properties and methods that live on the
component, including calling them with arguments — @click="dismiss()",
x-show="!isValidAsin(asin)". So the fix is always the same: put the logic in
an Alpine.data() component and pass what it needs through data-*
attributes.
The theme ships the two components sites reach for most.
<a href="/tickets/" x-data="ryderTrack" @click="track"
data-track-event="ticket_link_click"
data-track-props='{"venue":"The Roxy"}'>Tickets</a>The event name and props are read off the clicked element and forwarded to
whichever provider analytics_provider selects (PostHog and
Plausible are both handled). It is deliberately forgiving: if no provider is
present — unset, or the script was blocked by an ad blocker — the click is a
silent no-op rather than an error, and malformed data-track-props JSON warns in
the console and degrades to {} instead of breaking the handler, which is often
also responsible for a navigation.
data-track-props is optional and must be a JSON object. Mind the quoting:
single quotes outside, double quotes inside.
<form x-data="ryderForm" @submit.prevent="submit"
data-form-action="https://api.example.com/subscribe"
data-track-event="signup_submit">
<input type="email" name="email" required>
<!-- honeypot: bots fill it, humans never see it -->
<input type="text" name="_gotcha" tabindex="-1" autocomplete="off"
class="hidden" aria-hidden="true">
<button type="submit" :disabled="isLoading">Subscribe</button>
<p x-show="isSuccess">Thanks!</p>
<p x-show="isError" x-text="errorMessage"></p>
</form>Every named field is serialized to a JSON object and POSTed to
data-form-action.
| Property | Meaning |
|---|---|
status |
'', 'loading', 'success' or 'error' |
isIdle / isLoading / isSuccess / isError |
booleans for x-show and :disabled |
errorMessage |
failure message; override the default with data-error-message |
The booleans exist because the CSP evaluator cannot evaluate a comparison like
status === 'success' — only a plain property lookup — so x-show needs
something that is already a boolean.
A field named _gotcha is a spam honeypot: if it has a value the component
reports success and sends nothing, and it is never included in the payload. An
optional data-track-event on the <form> fires through the ryderTrack path
above, on success only.
CSP: posting to another origin requires the action's host in connect-src,
or the browser blocks the request:
[params.csp]
connectSrc = "https://api.example.com"Same-origin actions need nothing; connect-src 'self' is always present.
ryderForm is the engine; utils/form-field.html is the matching visual layer,
added in v0.4.0 so a themed form no longer means hand-writing the markup above:
<form x-data="ryderForm" @submit.prevent="submit"
data-form-action="https://api.example.com/subscribe">
{{ partial "utils/form-field.html" (dict "name" "email" "type" "email"
"label" "Email" "placeholder" "you@example.com" "required" true) }}
{{ partial "utils/form-field.html" (dict "name" "message" "type" "textarea"
"label" "Message" "help" "Anything else we should know?") }}
<input type="text" name="_gotcha" tabindex="-1" autocomplete="off"
class="hidden" aria-hidden="true">
{{ partial "utils/form-field.html" (dict "type" "submit" "label" "Subscribe"
"successMessage" "Thanks!" "errorMessage" "") }}
</form>
| Key | Meaning |
|---|---|
name |
Field name, and the default id. Required except for submit |
type |
Any text-like input type, plus textarea and submit. Defaults to text |
label |
Visible label; the button text when type is submit |
placeholder, value, autocomplete, rows |
Passed through when set |
required |
Marks the control required and appends * to the label |
help |
Hint text under the control, wired up via aria-describedby |
id, class |
Override the derived id, or append classes to the control |
successMessage |
submit only — a line shown on x-show="isSuccess" |
errorMessage |
submit only — a fixed message, or "" to show the engine's own |
The submit button reuses the theme's default CTA class, so it follows
params.colors.legacyAccents along with every other CTA. The honeypot stays
hand-written: it is one line, and it should not look like a field.
In hugo.Environment == "development" the theme also loads
assets/js/cspLint.js, which scans the rendered page for Alpine directives the
CSP evaluator cannot run — globals and arrow functions — and console.warns
naming the offending element. It never loads in any other environment. Re-run it
by hand over dynamically rendered content with __ryderCspLint().
For anything beyond the shipped components, assets/js/extended.js is the
theme's sanctioned extension point. In the theme it is a comment-only stub
imported by the last line of assets/js/main.js. Create the same path in your
own project:
your-site/assets/js/extended.js
Hugo's union asset filesystem gives your project's assets/ precedence over the
theme's, so your file replaces the stub with no theme edit, no fork, and nothing
to re-merge on upgrade. (exampleSite/assets/js/extended.js in this repo does
exactly that.) Your code is bundled into main.js by the same js.Build call,
so imports, JSX-free ESM, and node_modules packages all work.
Because ES import statements are hoisted, extended.js runs before
Alpine.start() — early enough to register your own components. It runs before
window.Alpine is assigned, though, so register on the alpine:init event
rather than reaching for window.Alpine at the top level:
// assets/js/extended.js
document.addEventListener('alpine:init', () => {
window.Alpine.data('myWidget', () => ({
open: false,
toggle() { this.open = !this.open },
// Read config off the element instead of inlining it in the template.
init() { this.endpoint = this.$el.dataset.endpoint },
}))
})<div x-data="myWidget" data-endpoint="/api/thing">
<button @click="toggle">Toggle</button>
<div x-show="open">…</div>
</div>Add extra Font Awesome icons here too:
import { library } from '@fortawesome/fontawesome-svg-core'
import { faSmileWink } from '@fortawesome/free-regular-svg-icons'
library.add(faSmileWink)assets/js/main.js imports @alpinejs/csp, @alpinejs/focus, leaflet, @fortawesome/fontawesome-svg-core, @fortawesome/free-solid-svg-icons, @fortawesome/free-regular-svg-icons, and @fortawesome/free-brands-svg-icons. They're declared in this theme's own package.json for the theme's dev loop and exampleSite, but that is documentation, not an install mechanism — Hugo's js.Build resolves imports from your site's project root node_modules, not the theme's. A theme consumed as a git submodule (or Hugo Module) is never npm install-ed itself, so you must install these packages at your own project root regardless of anything in the theme:
npm i @alpinejs/csp @alpinejs/focus leaflet \
@fortawesome/fontawesome-svg-core \
@fortawesome/free-solid-svg-icons \
@fortawesome/free-regular-svg-icons \
@fortawesome/free-brands-svg-iconsIf they're missing, hugo build/hugo server prints a warning naming the missing packages and this same install command before js.Build fails (or, if you rely on dependency hoisting and the packages genuinely resolve from elsewhere, the warning is a harmless false positive and the build proceeds).
Using Ryder? Let us know to be added here.
Ryder is an open source project by Arts-Link, a small creative studio building tools and sites for writers, artists, bands, and independent publishers. It is actively maintained by Ben Strawbridge.
If Ryder is useful to you, check out arts-link.com — and feel free to ⭐ the repo.