v0.4.0
v0.4.0 (2026-07-11)
Minor Changes
-
Markdown block elements now support
{.class #id key=value}attribute syntax beyond headings. Fenced code blocks accept attributes on the opening fence line, while blockquotes and tables accept them on the trailing line.```go {.highlight #example} fmt.Println("hello") ``` > This is important {.callout} | Name | Role | | ----- | -------- | | Alice | Engineer | {.striped}
Attributes are available in render hooks via
markup.attributesfor all block element types — headings, fenced code blocks, blockquotes, and tables. When no attributes are present,markup.attributesis an empty map.<!-- layouts/render-codeblock.liquid --> <pre class="{{ markup.attributes.class }}" data-lang="{{ markup.language }}">{{ markup.inner }}</pre>
Block-level attributes are automatically enabled when
autoHeadingIDis true (the default). No additional configuration is needed. -
Add
includefunction to the Go template engine, providing parity with Liquid's{% include %}for cross-file template inclusion.{{ include "partials/header" }} {{ include "partials/card" (dict "item" . "compact" true) }} {{ $nav := include "partials/nav" }}Includes resolve from the layouts directory (
.htmlextension, then raw name), support nested calls, and default to the current context when no argument is given. -
Add
limitarray filter that returns the first N elements, giving Go templates parity with Liquid.Go template:
{{ range limit .collections.blog 5 }} <h2>{{ .data.title }}</h2> {{ end }}Liquid:
{% for post in collections.blog | limit: 5 %} <h2>{{ post.data.title }}</h2> {% endfor %} -
Liquid layout resolution now falls back to bare extensions when
.liquidfiles are missing. For each candidate in the lookup chain, the Liquid engine tries.liquidfirst then the bare extension (e.g.,default.html,single.json) and parses the result as Liquid.This applies to standard page layouts, output format layouts (
single.json.liquid→single.json), taxonomy layouts, and parent layouts in layout chains.Layout names with a recognized extension (e.g.,
layout: "base.html",layout: "feed.xml") are now used as literal filenames — no engine extension is appended. Bare names without an extension (e.g.,layout: "base") get the.liquid→.htmlfallback. -
Breaking: Remove automatic filename-based layout matching from layout resolution. Previously,
content/docs/getting-started.mdwould automatically matchlayouts/getting-started.liquid— creating ambiguity when multiple content directories had pages with the same filename.Layouts are now resolved via three mechanisms only:
- Explicit
layout:in front matter or_data.yamlcascade - Date-based convention (
post.liquidfor section children,<section>.liquidfor index pages) default.liquidfallback
If you relied on filename matching, add an explicit
layout:to your front matter or_data.yaml:# content/docs/getting-started.md --- layout: "getting-started" ---
# content/docs/_data.yaml — applies to all pages in docs/ layout: "docs-page"
- Explicit
-
The
permalinks:key inalloy.config.yamlhas been removed. Permalink patterns are now set exclusively via_data.yamlcascade files in each section directory. Existing config files that still contain apermalinks:key will load without error — the key is silently ignored.Before (no longer supported):
# alloy.config.yaml permalinks: blog: "/:year/:month/:slug/"
After:
# content/blog/_data.yaml permalink: "/:year/:month/:slug/"
-
Render hook templates now receive richer context for links and headings.
Link hooks receive
markup.titlefrom the Markdown link title syntax[text](url "title").<a href="{{ markup.destination }}" title="{{ markup.title }}">{{ markup.text }}</a>
Heading hooks receive
markup.inneras rendered HTML (preserving inline formatting like<strong>),markup.textas plain text, andmarkup.attributesas a map of goldmark attributes from{.class #id key=value}syntax.markup.iduses the explicit{#custom-id}attribute when present, falling back to the auto-generated slug.<h{{ markup.level }} id="{{ markup.id }}" class="{{ markup.attributes.class }}"> {{ markup.inner }} </h{{ markup.level }}>
-
Sitemap generation can now be disabled site-wide with
sitemap: falsein the config file. When omitted, sitemaps are generated by default.# alloy.config.yaml sitemap: false
-
Front matter permalinks can now use full template syntax with
{{ }}expressions. When a permalink contains{{, it is rendered through the configured template engine (Liquid or Go templates) with apage.*context containing all front matter fields, date, slug, summary, and collection.# content/blog/hello.md --- title: Hello World slug: hello-world lang: en permalink: "/{{ page.lang }}/{{ page.slug }}/" ---
# Go template engine (templates.engine: "gotemplate") --- permalink: "/posts/{{ .page.title | slugify }}/" ---
Template and token syntax are separate modes. When
{{is detected, token syntax (:year,:slug) is not resolved — the entire string is a template expression. A template permalink that renders to an empty or whitespace-only string is a fatal build error, distinct frompermalink: falsewhich is an intentional opt-out.Pagination template permalinks now respect the configured engine. Previously,
engine: "gotemplate"pagination pages fell back to Liquid for permalink rendering. -
TOC generation can now be disabled site-wide with
content.markdown.toc: falsein the config file. When omitted,page.tocis populated for all Markdown pages by default.# alloy.config.yaml content: markdown: toc: false
-
Format layouts (JSON, XML, etc.) now follow the same predictable lookup order as HTML layouts. The format name is inserted before the template extension, so a single bare layout name drives all output formats.
The
layoutfront-matter value must be a bare name with no extension:layout: article # correct — resolves to article.json.liquid, article.xml.liquid, etc. # layout: article.liquid # build error — extension-bearing names cannot be used with format outputs outputs: [html, json]
Using an extension-bearing layout name (e.g.,
article.liquid,feed.xml) with format outputs is now a build error. The error message tells you what to fix:extension-bearing layout "article.liquid" cannot be used with format outputs; use
layout: articleinsteadWith
layoutset in front matterAlloy looks for the named layout with the format infixed. For
layout: articlewith JSON output:layouts/article.json.liquidlayouts/article.json(bare-extension fallback)
If neither exists, the build errors with the layout name, page, and format.
Without
layoutin front matterAlloy walks the auto candidate chain. For a blog post with JSON output:
layouts/post.json.liquid— date-based section childlayouts/post.json— bare-extension fallbacklayouts/my-post.json.liquid— matches the content filenamelayouts/my-post.json— bare-extension fallbacklayouts/default.json.liquid— final fallbacklayouts/default.json— bare-extension fallback
Each candidate tries
.format.liquidfirst, then the bare format extension. Higher-priority candidates (including their bare fallback) are checked before lower-priority ones.Cascade layouts from
_data.yamlalso apply to format outputs, with front-matter taking priority as expected.
Patch Changes
-
Accept
"go"as an alias for"gotemplate"in thetemplates.engineconfig field, and reject unknown engine values with a clear error instead of silently falling through to Liquid. -
Remove the extensionless (raw name) fallback from partial/include resolution in both template engines. Previously,
{% include "widget" %}and{{ include "widget" }}would trywidget.liquid,widget.html, and thenwidget(no extension) as a final fallback. A template file without an extension has no clear use case and is almost certainly a mistake.Both engines now try only recognized extensions:
- Liquid:
widget.liquid, thenwidget.html - Go templates:
widget.htmlonly
Sites relying on extensionless template files will see a build error referencing the partial name.
- Liquid:
-
Fix Go template engine format layouts resolving to
name.format.html(e.g.,default.json.html) instead of the correct bare format extension (default.json,feed.xml). The format extension is now used directly as the file extension, solayouts/feed.xmlrenders XML output andlayouts/api.jsonrenders JSON output without an.htmlsuffix. -
Fix language-specific
_data.yamlpermalink patterns being ignored in multi-language builds. Apermalinkset incontent/es/blog/_data.yamlnow correctly applies to pages incontent/es/blog/, instead of falling back to the default path-based URL. -
Tighten
{% inline %}tag validation. Paths must now start with./or../— bare paths like{% inline "diagram.svg" %}are rejected; use{% inline "./diagram.svg" %}instead. Only text-based extensions are accepted:.svg,.html,.htm,.txt,.css,.js,.json,.xml,.toml,.yaml,.yml,.md. Other file types produce a clear error, and binary types like.pngsuggest using<img>instead. -
Fix nested
_data.yamlpermalink patterns being silently ignored. Previously, only top-level section patterns were applied — acontent/blog/posts/_data.yamlwithpermalink: "/blog/:year/:month/:slug/"had no effect. Permalink resolution now uses the nearest_data.yamlin the directory tree, so subdirectories can override their parent's URL pattern.# content/blog/_data.yaml — simple slugs for static pages permalink: "/blog/:slug/" # content/blog/posts/_data.yaml — date-based URLs for posts permalink: "/blog/:year/:month/:slug/"
A page at
content/blog/posts/first-post.mdnow correctly resolves to/blog/2026/04/first-post/instead of falling back to the parent's/blog/first-post/. -
Remove the broken
fingerprinttemplate filter. It hashed the path string instead of file contents and emitted no renamed file, so fingerprinted URLs would 404. Usecachebustfor query-string cache busting or a plugin for filename-rewriting fingerprinting. -
Remove dead
ResolveForSectionfunction from the permalink package. After issue #910 wired all permalink resolution throughResolveFromCascade,ResolveForSectionhad zero production call sites. Its flatmap[string]stringsection→pattern lookup silently dropped nested_data.yamlpermalink patterns — all coverage has been ported toResolveFromCascadetests. -
absolute_urlnow prepends the site's configuredbaseURLautomatically when no explicit argument is passed. Theurlfilter prepends the path portion ofbaseURLto relative paths.