v0.5.0
v0.5.0 (2026-07-17)
Minor Changes
-
onPagesReadyhooks accept a second return shape,{ addPages: [...] }, for injecting virtual pages without round-tripping the entire pages array through the plugin bridge.alloy.hook('onPagesReady', { data: ["elements"], pages: false }, function(payload) { var newPages = payload.siteData.elements.map(function(el) { return { path: 'demos/' + el.slug + '.md', url: '/demos/' + el.slug + '/', frontMatter: { title: el.name, layout: 'default' }, content: '# ' + el.name }; }); return { addPages: newPages }; });
With
pages: false, the plugin receives onlysiteData. Alloy skips serialization of existing pages and appends theaddPagesentries Go-side, cutting the O(N) cost of returning all pages.Virtual pages from
addPagesflow through the remaining pipeline: taxonomy collection, content rendering, layout resolution, and output writing. They appear intaxonomies.*template variables and count towardPageCount.The
{ pages: [...] }return shape still works for plugins that mutate existing pages. The two shapes are mutually exclusive: returning both produces a build error. Returning an unrecognized key (e.g.,{ newPages: [...] }) now errors instead of silently dropping pages. -
Alloy loads data files from subdirectories of
data/into nested template namespaces.data/nav/main.yamlbecomessite.data.nav.main. Nesting goes to any depth:data/api/v2/endpoints.yamlbecomessite.data.api.v2.endpoints.# data/nav/main.yaml items: - label: Home url: / - label: About url: /about/
{% for item in site.data.nav.main.items %} <a href="{{ item.url }}">{{ item.label }}</a> {% endfor %}Place root-level data files alongside subdirectory namespaces in the same
data/directory. A file and directory sharing the same stem (nav.yamlalongside anav/directory) produces a build error, matching the collision behavior for same-stem files in different formats. Alloy skips empty subdirectories.Alloy skipped subdirectories of
data/without warning in prior releases. Organizing files into folders produced no template output despite the documented "any structure" guidance. -
Go template block shortcodes use Hugo-style
{{% tag "args" %}}...{{% /tag %}}delimiters. A preprocessor runs after Goldmark and before Go template rendering — it pairs opening and closing tags, extracts quoted arguments, passes inner HTML to the shortcode callback, and replaces the block with the callback's output.{{% callout "warning" %}} This is **important** content with [links](/). {{% /callout %}}Nesting resolves innermost-first. Same-name nesting (
{{% box %}}{{% box %}}...{{% /box %}}{{% /box %}}) uses depth tracking. Delimiters inside<pre>and<code>elements are treated as literal text. Unclosed tags, mismatched names, and callback errors produce build errors.Goldmark now treats standalone
{{% tag %}}lines as block-level nodes, preventing<p>wrapping. Inner content between paired tags is Markdown-processed before reaching the preprocessor.Previously, Go template block shortcodes always received empty
contentbecausegoEngine.AddTaghard-coded it to"". -
onAssetProcessfires once per asset file with{path, content}instead of once per build with directory paths. The returnedcontentkey replaces the file in the output directory.alloy.hook("onAssetProcess", {}, (asset) => { if (asset.path.endsWith('.css')) { return { content: minifyCSS(asset.content) }; } return asset; });
Return
null,undefined, or an object without acontentkey to keep the original file. The build ignores anypathkey you return. Hook errors stop the build.Before this change, the hook received
{assetsDir, outputDir}directory paths and discarded the return value. Plugins that followed the docs were silent no-ops. -
onConfighooks can mutate pipeline config. The return value is applied back tocfgfor a mutable allowlist:build.output,build.clean,structure.content,structure.layouts,structure.assets,structure.static,structure.data,passthrough,plugins.workers, andplugins.timeout.alloy.hook("onConfig", {}, (config) => { config.build.output = "dist"; config.structure.content = "pages"; return config; });
Fields outside the allowlist (
title,baseURL,language, etc.) are silently ignored. Returning a non-object produces a build error. MultipleonConfighooks from separate plugins chain in priority order — each receives the previous hook's return value.Previously the return value was discarded and mutations had no effect.
-
alloy.source(name, fn)registers a data source handler in Node plugins. Configuretype: "plugin"insources:to route data acquisition through the handler instead of a REST or GraphQL endpoint.# alloy.config.yaml sources: blog: type: "plugin" plugin: "cms-posts" cache: 3600 as: "blog"
// plugins/cms.js export const runtime = "node"; export default function(alloy) { alloy.source("cms-posts", async (config) => { const resp = await fetch("https://api.example.com/posts"); return resp.json(); }); }
Returned data merges into
site.dataunder theaskey (or the source map key whenasis omitted). Templates access it like any other data source:site.data.blog.size,{% for post in site.data.blog %}.Alloy caches plugin source results to
.alloy/fetch-cache/using the same TTL and--refetchsemantics as REST sources. A source handler error aborts the build. Duplicatealloy.source()calls for the same name produce a warning; the last registration wins.Source calls enforce a 5-second timeout matching
plugins.timeout. Slow handlers produce a timeout error instead of blocking the build. -
Liquid shortcode arguments resolve variables from the template context. Unquoted arguments like
{% youtube page.videoId %}look uppage.videoIdin the template context and pass the resolved value to the shortcode callback. Quoted arguments remain literal strings. Dotted paths traverse nested maps:page.videoIdresolves to thevideoIdkey inside thepagemap. Non-string values are converted to strings. When an unquoted argument does not match any context variable, it falls back to its literal token string.{% assign vid = "dQw4w9WgXcQ" %} {% youtube vid %} <!-- resolves vid to "dQw4w9WgXcQ" --> {% youtube "hardcoded" %} <!-- stays literal "hardcoded" --> {% youtube page.videoId %} <!-- resolves nested path -->Mixed quoted and unquoted arguments work in the same tag:
{% card "primary" page.size %}passes"primary"as a literal and resolvespage.sizefrom context.Shortcodes returning an empty string now produce no output in Liquid, matching Go template behavior. Previously, empty-returning shortcodes emitted an
<alloy-shortcode>placeholder element into production HTML. -
Check for newer Alloy releases with
alloy version --check. Alloy queries the GitHub Releases API and compares the latest tag against the running binary.alloy version --checkSet
updateCheck: truein the config file to receive a one-line notification whenalloy devoralloy servestarts and a newer version exists. Alloy caches the result for 24 hours at~/.config/alloy/update-check.json(respectsXDG_CONFIG_HOME) and runs the check in the background without blocking server startup.alloy buildnever checks for updates.# alloy.config.yaml updateCheck: true
Update checking defaults to off. Alloy makes no outbound request unless you opt in via the config or use
--check. -
onBeforeValidationreceives{ outputPaths: [...] }and runs immediately before conflict detection. Plugins register additional output paths viaaddOutputs, and those paths feed intoDetectConflicts().alloy.hook("onBeforeValidation", {}, (payload) => { return { addOutputs: { "sitemap.xml": "plugin:sitemap", "robots.txt": "plugin:sitemap" } }; });
onAfterValidationreceives{ outputPaths: [...], cascade: { ...siteData... } }after conflict detection passes. Cascade mutations merge into site data for templates. The pipeline ignoresoutputPathschanges in the return.alloy.hook("onAfterValidation", {}, (payload) => { payload.cascade.buildTimestamp = new Date().toISOString(); return payload; });
Both hooks reject unrecognized return keys and type-check
addOutputs/cascadeas maps. Omitting a return value is a valid no-op for observation-only use.Previously, the pipeline fired both hooks before content discovery with a stub payload and threw away the return values.
-
onPagesReadyvirtual pages accept adependenciesarray of project-root-relative file paths. On incremental rebuilds, Alloy re-renders virtual pages whose dependencies appear inchangedFilesand skips the rest.alloy.hook('onPagesReady', { pages: false }, function() { const demoFiles = glob.sync('elements/*/demo/*.html'); const pages = demoFiles.map(file => ({ path: 'demos/' + path.basename(file), url: '/demos/' + path.basename(file, '.html') + '/', dependencies: [file], frontMatter: { layout: 'demo', markdown: false }, content: fs.readFileSync(file, 'utf-8') })); return { addPages: pages }; });
dependencies: ['a.html', 'b.css']— re-render when a listed file changes, skip otherwisedependencies: []— skip (no local file deps to invalidate)- no
dependenciesfield — re-render on all incremental rebuilds (pre-#1058 behavior)
A site with 400 file-derived virtual pages previously re-rendered all 400 per incremental rebuild. Declaring dependencies narrows that to the pages whose source files changed.
Patch Changes
-
Fix batch hooks firing a spurious timeout warning when called with 0 items. The effective timeout was calculated as
timeout * itemCount, which produced a 0ms timeout that expired instantly. Alloy now skips the hook when there are no payloads to process. This surfaces during incremental rebuilds where scope filtering leaves 0 pages for post-render hooks likeonPageRendered. -
onContentLoadednow applieshtmlmutations back to page state viaSetRenderedBody. Previously onlyfrontMatterchanges were merged back;htmlmutations were silently dropped.alloy.hook("onContentLoaded", { pages: true, pageFields: ["*"] }, (pages) => { for (const page of pages) { page.html = page.html + "<footer>Injected</footer>"; } return pages; });
Both
htmlandfrontMattermutations work independently or together in the same hook call. The fix applies to bothBuild()andBuildIncremental(). -
onConfighooks that setpassthrough[].fromorpassthrough[].toto an absolute path or a..traversal above the project root now produce a build error. The validator rejectsfrom: "."(would copy the entire project root into the output directory).to: "."andto: ""remain valid, meaning "root of the output directory." Error messages include the zero-based array index and field name (e.g.passthrough[2].from).Passthrough path validation runs before the config is applied, so a bad passthrough entry cannot half-mutate
build.outputorstructure.*fields.Previously, a plugin could set
passthrough[].fromto/etc/shadowto exfiltrate files into the output directory, orpassthrough[].toto../../evilto write files outside it. -
onConfighooks that setbuild.outputor anystructure.*field to an absolute path, a..traversal above the project root,., or an empty string now produce a build error. Valid relative paths with embedded..segments that resolve within the project (e.g.subdir/../dist) are accepted and cleaned before use. On Windows, reserved device names (NUL,CON) and volume-relative paths (C:..) are also rejected viafilepath.IsLocal.All path fields are validated before any are applied to the config, so a validation failure on one field cannot leave the config partially mutated.
Previously, a plugin could set
structure.content = "/etc"orbuild.output = "../../evil"viaonConfigand the values flowed throughresolveDirunchecked. Withclean: true(the default),CleanOutputDirwould runos.RemoveAllon directories outside the project tree. -
Fix omitted
pagesscope defaulting to "all pages" instead of "none". Hooks registered with{}or{data: ["elements"]}produced a spurious validation warning on pageless events likeonConfigandonBuildComplete. Batch hooks also serialized all pages when the plugin had not requested page data. Plugins that want pages must declarepages: true. -
Plugin hooks that exceed their timeout no longer cause a panic during build teardown.
Close()waits for any in-flight hook, filter, or shortcode call to finish before releasing the QuickJS runtime.Previously, a timed-out plugin hook could trigger an
out of bounds memory accesspanic at the end ofBuild()because the runtime was freed while the hook was still executing. -
Fix Node bridge protocol corruption when a plugin or its dependencies call
process.stdout.write(). The bridge captures the realprocess.stdout.writeat startup and redirects subsequent stdout writes to stderr.sendMessageretains the only reference to real stdout, so plugin code and npm dependencies cannot inject bytes into the JSON-RPC channel.When non-frame bytes reach the Go frame reader (e.g., a child process inheriting stdout), the error message includes a bounded snippet of the offending output and points to stdout pollution as the cause. Replaces the generic "missing Content-Length header" message.
-
Fix
alloy devskipping virtual pages fromonPagesReadyplugins on incremental rebuilds. Alloy tracks virtual page paths across rebuilds, so plugin-generated pages (demos, API docs, CMS-driven content) re-render when source files change instead of serving stale content.