QuickJS Plugins

QuickJS plugins are plain JavaScript files that run on an embedded QuickJS engine inside the Alloy process. No Node.js dependency, no build step – drop a .js file in plugins/ and it works immediately.

// plugins/reading-time.js
export default function(alloy) {
  alloy.filter("readingTime", (content) => {
    const words = content.split(/\s+/).filter(w => w.length > 0).length;
    const minutes = Math.ceil(words / 200);
    return `${minutes} min read`;
  });
}
<span>{{ page.content | readingTime }}</span>

How It Works

Alloy embeds a single QuickJS instance compiled to WASM, running via wazero (pure Go, zero CGo). Your .js files are evaluated in this shared context at startup.

  • Startup cost: ~10-50ms one-time
  • Per-call cost: ~10-50 microseconds
  • Memory: ~2-4MB

QuickJS plugins are sandboxed – they cannot access the filesystem, network, or system resources.

Registering Filters

Filters transform values in template expressions:

// plugins/filters.js
export default function(alloy) {
  // Simple string filter
  alloy.filter("initials", (name) => {
    return name.split(' ').map(w => w[0]).join('');
  });

  // Filter with arguments
  alloy.filter("truncateAt", (text, maxLength) => {
    if (text.length <= maxLength) return text;
    return text.slice(0, maxLength) + '...';
  });

  // Numeric filter
  alloy.filter("percentage", (value, total) => {
    return Math.round((value / total) * 100) + '%';
  });
}

Use in templates:

{{ page.author | initials }}
{{ page.description | truncateAt: 120 }}
{{ page.score | percentage: page.maxScore }}

Filter arguments are passed as additional parameters after the input value.

Registering Shortcodes

Shortcodes embed rich HTML snippets in content files:

// plugins/shortcodes.js
export default function(alloy) {
  // Inline shortcode (self-closing)
  alloy.shortcode("youtube", (args) => {
    const id = args[0];
    return `<iframe src="https://www.youtube.com/embed/${id}"
            frameborder="0" allowfullscreen></iframe>`;
  });

  // Block shortcode (wraps content)
  alloy.shortcode("callout", (args, content) => {
    const type = args[0] || "info";
    return `<div class="callout callout--${type}">${content}</div>`;
  });

  // Shortcode using site data
  alloy.shortcode("componentDemo", (args) => {
    const tagName = args[0];
    const elements = alloy.data.elements || [];
    const el = elements.find(e => e.tagName === tagName);
    if (!el) return `<!-- unknown component: ${tagName} -->`;
    return `<div class="demo">
      <h3>${el.name}</h3>
      <${el.tagName}></${el.tagName}>
    </div>`;
  });
}

Use in content:

{% youtube "dQw4w9WgXcQ" %}

{% callout "warning" %}
Do not use this in production without testing first.
{% endcallout %}

{% componentDemo "rh-button" %}

Registering Hooks

Hooks let plugins run code at specific points in the build pipeline:

// plugins/transforms.js
export default function(alloy) {
  // Add lazy loading to all images
  alloy.hook("onContentTransformed", {}, (page) => {
    page.html = page.html.replace(/<img /g, '<img loading="lazy" ');
    return page;
  });

  // Minify final HTML output
  alloy.hook("onPageRendered", {}, (html) => {
    return html.replace(/\s+/g, ' ').trim();
  });
}

Hook Options

The second argument to alloy.hook() is a required options object:

// Control execution order with priority (lower runs first, default 50)
alloy.hook("onPageRendered", { priority: 10 }, earlyTransformFn);
alloy.hook("onPageRendered", { priority: 100 }, lateTransformFn);

// Declare what data the hook needs (reduces serialization cost)
alloy.hook("onContentLoaded", {
  data: ["navigation"],     // only serialize these site.data keys
  pages: "/blog/**",        // only receive blog pages
  pageFields: ["frontMatter", "url"]  // only these fields per page
}, fn);

See Lifecycle Events for all available hooks and Hook Scoping for the full scoping API.

Accessing Site Data

alloy.data provides read-only access to the same data available as site.data in templates:

export default function(alloy) {
  alloy.filter("teamMember", (slug) => {
    const team = alloy.data.team || [];
    const member = team.find(m => m.slug === slug);
    return member ? member.name : slug;
  });
}

Access alloy.data inside filter, shortcode, and hook functions – not at the top level of your plugin. During plugin evaluation, alloy.data is undefined.

To modify data, use hooks like onDataFetched:

alloy.hook("onDataFetched", { data: ["team"] }, (data) => {
  data.teamCount = data.team.length;
  return data;
});

QuickJS vs Node

A .js file in plugins/ runs on QuickJS by default. If your plugin needs Node APIs or npm packages, add runtime: "node":

// This runs on QuickJS (default)
export default function(alloy) { /* ... */ }

// This runs on Node (Tier 3)
export const runtime = "node";
export default function(alloy) { /* ... */ }

See Node Plugins for details on Tier 3 plugins.

Limitations

  • No filesystem access (fs, path)
  • No network access (fetch, http)
  • No Node.js APIs or npm packages
  • No require() or import of external modules
  • alloy.data is read-only – mutations do not propagate to templates

For any of these capabilities, use a Node plugin instead.