Skip to content

v0.4.0

Choose a tag to compare

@github-actions github-actions released this 11 Jul 06:10
41f7720

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.attributes for all block element types — headings, fenced code blocks, blockquotes, and tables. When no attributes are present, markup.attributes is 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 autoHeadingID is true (the default). No additional configuration is needed.

  • Add include function 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 (.html extension, then raw name), support nested calls, and default to the current context when no argument is given.

  • Add limit array 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 .liquid files are missing. For each candidate in the lookup chain, the Liquid engine tries .liquid first 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.liquidsingle.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.html fallback.

  • Breaking: Remove automatic filename-based layout matching from layout resolution. Previously, content/docs/getting-started.md would automatically match layouts/getting-started.liquid — creating ambiguity when multiple content directories had pages with the same filename.

    Layouts are now resolved via three mechanisms only:

    1. Explicit layout: in front matter or _data.yaml cascade
    2. Date-based convention (post.liquid for section children, <section>.liquid for index pages)
    3. default.liquid fallback

    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"
  • The permalinks: key in alloy.config.yaml has been removed. Permalink patterns are now set exclusively via _data.yaml cascade files in each section directory. Existing config files that still contain a permalinks: 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.title from the Markdown link title syntax [text](url "title").

    <a href="{{ markup.destination }}" title="{{ markup.title }}">{{ markup.text }}</a>

    Heading hooks receive markup.inner as rendered HTML (preserving inline formatting like <strong>), markup.text as plain text, and markup.attributes as a map of goldmark attributes from {.class #id key=value} syntax. markup.id uses 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: false in 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 a page.* 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 from permalink: false which 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: false in the config file. When omitted, page.toc is 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 layout front-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: article instead

    With layout set in front matter

    Alloy looks for the named layout with the format infixed. For layout: article with JSON output:

    1. layouts/article.json.liquid
    2. layouts/article.json (bare-extension fallback)

    If neither exists, the build errors with the layout name, page, and format.

    Without layout in front matter

    Alloy walks the auto candidate chain. For a blog post with JSON output:

    1. layouts/post.json.liquid — date-based section child
    2. layouts/post.json — bare-extension fallback
    3. layouts/my-post.json.liquid — matches the content filename
    4. layouts/my-post.json — bare-extension fallback
    5. layouts/default.json.liquid — final fallback
    6. layouts/default.json — bare-extension fallback

    Each candidate tries .format.liquid first, then the bare format extension. Higher-priority candidates (including their bare fallback) are checked before lower-priority ones.

    Cascade layouts from _data.yaml also apply to format outputs, with front-matter taking priority as expected.

Patch Changes

  • Accept "go" as an alias for "gotemplate" in the templates.engine config 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 try widget.liquid, widget.html, and then widget (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, then widget.html
    • Go templates: widget.html only

    Sites relying on extensionless template files will see a build error referencing the partial name.

  • 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, so layouts/feed.xml renders XML output and layouts/api.json renders JSON output without an .html suffix.

  • Fix language-specific _data.yaml permalink patterns being ignored in multi-language builds. A permalink set in content/es/blog/_data.yaml now correctly applies to pages in content/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 .png suggest using <img> instead.

  • Fix nested _data.yaml permalink patterns being silently ignored. Previously, only top-level section patterns were applied — a content/blog/posts/_data.yaml with permalink: "/blog/:year/:month/:slug/" had no effect. Permalink resolution now uses the nearest _data.yaml in 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.md now correctly resolves to /blog/2026/04/first-post/ instead of falling back to the parent's /blog/first-post/.

  • Remove the broken fingerprint template filter. It hashed the path string instead of file contents and emitted no renamed file, so fingerprinted URLs would 404. Use cachebust for query-string cache busting or a plugin for filename-rewriting fingerprinting.

  • Remove dead ResolveForSection function from the permalink package. After issue #910 wired all permalink resolution through ResolveFromCascade, ResolveForSection had zero production call sites. Its flat map[string]string section→pattern lookup silently dropped nested _data.yaml permalink patterns — all coverage has been ported to ResolveFromCascade tests.

  • absolute_url now prepends the site's configured baseURL automatically when no explicit argument is passed. The url filter prepends the path portion of baseURL to relative paths.