Lifecycle Events
Lifecycle hooks let plugins run code at specific points in the build pipeline. Register a hook with alloy.hook() or alloy.on() to modify content, inject pages, transform data, or observe build events.
// plugins/lazy-images.js
export default function(alloy) {
alloy.hook("onContentTransformed", {}, (page) => {
page.html = page.html.replace(/<img /g, '<img loading="lazy" ');
return page;
});
}
Hooks work identically across all plugin tiers (QuickJS, WASM, Node). Payloads are JSON-serializable.
Hook Registration
alloy.hook(eventName, options, handlerFn);
// or equivalently:
alloy.on(eventName, options, handlerFn);
The options object is required. It controls execution order and payload scoping:
alloy.hook("onPageRendered", {
priority: 10, // lower runs first (default 50)
data: ["navigation"], // site.data keys to include
pages: "/blog/**", // page filter (glob)
pageFields: ["frontMatter", "url"] // fields per page
}, fn);
See Hook Scoping for the full scoping API.
All Lifecycle Events
Per-Build Hooks
These fire once per build. Payloads are JSON objects representing build-level state.
onConfig
Fires after config is loaded. Plugin can mutate configuration values.
alloy.hook("onConfig", {}, (config) => {
config.build.output = "dist";
return config;
});
| Field | Description |
|---|---|
title |
Site title |
baseURL |
Site base URL |
build |
Build settings (output, clean) |
| … | All config fields |
onDataFetched
Fires after external data sources are fetched and merged into site data. Plugin can modify or enrich the data.
alloy.hook("onDataFetched", { data: ["team"] }, (data) => {
if (data.team) {
data.teamCount = data.team.length;
data.teamByDepartment = {};
for (const member of data.team) {
const dept = member.department || "unassigned";
if (!data.teamByDepartment[dept]) data.teamByDepartment[dept] = [];
data.teamByDepartment[dept].push(member);
}
}
return data;
});
This is the primary mechanism for adding computed data that templates can access via site.data.*.
onBeforeValidation
Fires before output path conflict detection. Plugins can register additional output paths (e.g., _redirects or _headers for Netlify):
alloy.hook("onBeforeValidation", {}, (outputMap) => {
outputMap.add("_redirects", { source: "plugin:netlify-redirects" });
return outputMap;
});
onAfterValidation
Fires after validation passes. Plugins receive the validated output manifest (read-only) and the data cascade (mutable):
alloy.hook("onAfterValidation", {}, (payload) => {
payload.cascade.buildTimestamp = new Date().toISOString();
return payload;
});
Pre-Taxonomy Hook
onPagesReady
Fires once per language batch, after the data cascade is applied but before taxonomy collection. This is the injection point for virtual pages that need to participate in taxonomies.
alloy.hook("onPagesReady", { data: ["elements"], pages: false }, (payload) => {
var elements = payload.siteData.elements || [];
var newPages = [];
for (var i = 0; i < elements.length; i++) {
var el = elements[i];
newPages.push({
path: "demos/" + el.slug + ".md",
url: "/demos/" + el.slug + "/",
frontMatter: {
title: el.name + " Demo",
layout: "demo",
tags: [el.tagName]
},
content: "## " + el.name + "\n\n" + el.description
});
}
return { addPages: newPages };
});
Virtual page fields:
| Field | Required | Description |
|---|---|---|
path |
yes | Source-relative identifier (e.g., demos/button.md) |
url |
yes | Permalink (e.g., /demos/button/) |
frontMatter |
no | Page metadata, including taxonomy terms like tags |
content |
no | Raw markdown content (rendered through the pipeline) |
Virtual pages injected here flow through the full remaining pipeline: taxonomy collection, content rendering, layout resolution, and output writing.
When using pages: false in the options, return { addPages: [...] } to inject pages without round-tripping all existing pages through the plugin bridge.
Content Hooks
onContentLoaded
Fires once with the full pages array after content rendering. Modify frontMatter and html on existing pages. Other fields (content, path, url) are present for inspection but mutations are not applied back.
alloy.hook("onContentLoaded", {
pages: true,
pageFields: ["frontMatter", "html", "url"]
}, (pages) => {
pages.forEach(page => {
if (page.frontMatter.draft) {
page.frontMatter.noindex = true;
}
});
return pages;
});
The return array must be the same length and order as the input. Virtual page injection is not supported here – use onPagesReady instead.
onDataCascadeReady
Fires once with the full pages array after the data cascade is resolved. Each entry has the per-page cascade data. Plugin can enrich cascade data.
alloy.hook("onDataCascadeReady", { pages: true }, (pages) => {
pages.forEach(page => {
page.data.generatedAt = new Date().toISOString();
});
return pages;
});
Per-Page Hooks
These fire once per page. They receive page-scoped payloads.
onContentTransformed
Fires after Markdown-to-HTML conversion but before layout rendering. Receives a page-scoped object with html, toc, path, url, and frontMatter.
alloy.hook("onContentTransformed", {}, (page) => {
// Add lazy loading to images
page.html = page.html.replace(/<img /g, '<img loading="lazy" ');
// Build TOC for non-markdown pages
if (!page.toc || page.toc.length === 0) {
page.toc = extractHeadingsFromHTML(page.html);
}
return page;
});
onPageRendered
Fires after template rendering produces the final page HTML. Receives an HTML string and returns an HTML string.
alloy.hook("onPageRendered", {}, (html) => {
return html.replace(/\s+/g, ' ').trim();
});
Per-Asset Hook
onAssetProcess (Tier 3 Only)
Fires once per asset file during asset copy. Receives { path, content } and returns { content }.
alloy.hook("onAssetProcess", {}, async (asset) => {
if (asset.path.endsWith('.css')) {
return { content: await minifyCSS(asset.content) };
}
return asset;
});
Read-Only Hooks
Return values are ignored. Plugins observe but cannot modify.
onBuildComplete
alloy.hook("onBuildComplete", {}, (result) => {
console.log(`Built ${result.pageCount} pages in ${result.duration}`);
});
onDevServerStart
alloy.hook("onDevServerStart", {}, (info) => {
console.log(`Server ready at ${info.url}`);
});
onFileChanged
alloy.hook("onFileChanged", {}, (filePath) => {
console.log(`Changed: ${filePath}`);
});
Hook Execution Order
Hooks execute by priority (lower runs first), then by alphabetical plugin filename within the same priority. Each hook receives the output of the previous one – they chain, not race.
// Plugin A: runs first
alloy.hook("onPageRendered", { priority: 10 }, transformFn);
// Plugin B: runs second (default priority 50)
alloy.hook("onPageRendered", {}, analyticsFn);
// Plugin C: runs last
alloy.hook("onPageRendered", { priority: 100 }, ssrFn);
Hook Timeout
Each hook call is subject to the configured timeout (default 5 seconds). A timed-out hook produces a warning, its modifications are discarded, and the build continues with the pre-hook payload.
plugins:
timeout: 5000
Related
- Hook Scoping – control what data hooks receive
- Plugin System – plugin tiers and registration
- QuickJS Plugins – embedded JS plugins
- Node Plugins – subprocess plugins with npm access