Shortcodes

Shortcodes are reusable content snippets that accept arguments and output HTML. They let you embed rich elements in Markdown content without writing raw HTML.

{% youtube "dQw4w9WgXcQ" %}
<!-- Output -->
<iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ"
        frameborder="0" allowfullscreen></iframe>

Shortcodes are registered via plugins and used in content files as custom Liquid tags (or Go template functions).

Using shortcodes in content

Inline shortcodes

Inline shortcodes take positional arguments and produce self-contained output:

Liquid Go templates
{% youtube "dQw4w9WgXcQ" %}
{% github_star "alloy-ssg/alloy" %}
{{ youtube "dQw4w9WgXcQ" }}
{{ github_star "alloy-ssg/alloy" }}

Block shortcodes

Block shortcodes wrap inner content, letting you add markup around authored text:

Liquid Go templates
{% callout "warning" %}
  Do not deploy to production without running the test suite first.
{% endcallout %}
Liquid block shortcodes close with `{% end %}`.
{{% callout "warning" %}}
  Do not deploy to production without running the test suite first.
{{% /callout %}}
Go template block shortcodes use `{{% %}}` delimiters (double braces) and close with `{{% / %}}`.
<!-- Output (both engines) -->
<div class="callout callout--warning">
  Do not deploy to production without running the test suite first.
</div>

Raw block content

By default a block shortcode’s body is Markdown first and shortcode content second: Goldmark parses it before the shortcode callback ever runs. That is usually what you want, but it corrupts code-like content — < and & become entities, -- becomes an en dash, indented lines become code blocks, and under unsafe: false a <script> disappears entirely.

Add a > immediately after the opening delimiter to pass that invocation’s body through completely unparsed:

Liquid Go templates
{%> helmet %}
<script type="application/ld+json">
  { "@context": "https://schema.org", "name": "Alloy -- fast & extensible" }
</script>
{% endhelmet %}
{{%> helmet %}}
<script type="application/ld+json">
  { "@context": "https://schema.org", "name": "Alloy -- fast & extensible" }
</script>
{{% /helmet %}}

The shortcode callback receives the body byte-for-byte as written. The > is an Alloy-level marker — it is stripped before the template engine sees the tag, so your shortcode is registered and invoked exactly as normal.

Only the open tag carries the marker. Close tags keep each engine’s native syntax:

Liquid Go templates
Raw block open {%> tag "arg" %} {{%> tag "arg" %}}
Raw block close {% endtag %} {{% /tag %}}

The marker belongs to the call site

Raw is a property of one invocation, not of the shortcode itself. The same shortcode can take a raw body on one page and a Markdown-processed body on another — nothing changes in how you register it, and no plugin API is involved.

Markdown-only

The > marker applies to .md content files only. .html content files never go through Goldmark, so shortcode bodies there already reach the callback unmodified — the marker is meaningless rather than merely unsupported. Writing {%> in an .html file reaches the template engine verbatim and produces its native parse error (unknown tag in Liquid, unexpected closing tag in Go templates).

Raw bodies bypass unsafe: false

A raw body is emitted verbatim regardless of the goldmark.unsafe setting. A <script> inside a raw block passes through even on a site where raw HTML is otherwise stripped.

This is deliberate — passing script and structured-data content to a shortcode is the reason the feature exists — but it is a hole in HTML sanitization that you open explicitly, per invocation. Only use {%> with content you control.

Finding the close tag

The block ends at the close tag matching its own open tag, tracked by nesting depth. A balanced same-name pair inside the body nests correctly:

{%> callout %}
{% callout %}
this inner pair does not close the outer block
{% endcallout %}
still inside the raw body
{% endcallout %}

{% endcallout %}

Only tags alone on their own line affect depth. A tag sharing its line with other text (Close it with `{% endcallout %}`.) is body content. Delimiter families never cross: a {{% ... %}} line inside a Liquid raw block is body text, and vice versa.

The limitation is the same one a fenced code block has with its own fence — an unbalanced same-name close tag alone on its own line ends the block early. To document a close tag in isolation, keep it inline or use a non-raw block.

Unterminated blocks are a build error

A raw block that never closes fails the build rather than silently swallowing the rest of the file:

content transformation: blog/post.md: unterminated raw block shortcode {%> helmet %} opened at line 12: expected {% endhelmet %}

What raw does not do

The marker governs the Markdown stage only. Once the body reaches the template engine it is treated like any other block shortcode body, so engine-level delimiters inside it are still engine syntax. Raw controls how the body is parsed as Markdown, not what the engine does with it afterwards.

Engine differences

Liquid Go templates
Inline {% tag "arg" %} {{ tag "arg" }}
Block open {% tag "arg" %} {{% tag "arg" %}}
Block close {% endtag %} {{% /tag %}}
Raw block open {%> tag "arg" %} {{%> tag "arg" %}}
Inner content Rendered HTML Rendered HTML
Plugin callback (args, content) (args, content)

Both engines pass rendered HTML to the plugin callback — the same alloy.shortcode() plugin works for both engines with no engine-specific code.

Code block escaping

{{% %}} delimiters inside fenced code blocks and inline <code> elements are treated as literal text, not shortcode invocations.

Variable arguments (Liquid)

In Liquid templates, unquoted shortcode arguments resolve from the template context instead of being passed as literal strings:

{% assign vid = "dQw4w9WgXcQ" %}
{% youtube vid %}              <!-- resolves vid to "dQw4w9WgXcQ" -->
{% youtube "hardcoded" %}      <!-- stays literal "hardcoded" -->
{% youtube page.videoId %}     <!-- resolves nested path -->

Dotted paths like page.videoId traverse nested maps in the template context.

Mixed arguments

Quoted and unquoted arguments work in the same tag:

{% card "primary" page.size %}

The first argument is the literal string "primary". The second resolves page.size from the context.

Fallback behavior

When an unquoted argument does not match any context variable, it falls back to its literal token string. {% youtube nonexistent %} passes "nonexistent" to the shortcode callback. This preserves backward compatibility — existing shortcodes that used unquoted literal strings continue to work.

Empty return

Shortcodes that return an empty string produce no output. Previous versions emitted an <alloy-shortcode> placeholder element — this is no longer the case.

Registering shortcodes

Shortcodes are defined in plugin files placed in the plugins/ directory. No configuration is needed – drop a file in plugins/ and its shortcodes are immediately available in all content files.

JS plugin (Tier 2 – in-process)

The simplest way to define shortcodes. JS plugins run on embedded QuickJS with no build step:

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

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

The first argument to alloy.shortcode() is the tag name. The callback receives an args array of positional arguments. Block shortcodes receive a second content parameter containing the inner content.

Node plugin (Tier 3 – full Node.js access)

Use Tier 3 when your shortcode needs npm packages, filesystem access, or network calls:

// plugins/code-highlight.js
export const runtime = "node";
import prism from 'prismjs';

export default function(alloy) {
  alloy.shortcode("highlight", (args, content) => {
    const language = args[0] || "text";
    const html = prism.highlight(content, prism.languages[language], language);
    return `<pre class="language-${language}"><code>${html}</code></pre>`;
  });
}

Tier 3 plugins must have "type": "module" in the project’s package.json.

WASM plugin (Tier 2 – compiled)

For maximum performance, compile shortcodes to WASM from Rust, TinyGo, or AssemblyScript:

Rust:

// plugins/shortcodes.rs (compile with wasm-pack)
use alloy_plugin::*;

#[alloy_shortcode("youtube")]
fn youtube(args: Vec<&str>) -> String {
    let id = args[0];
    format!(
        r#"<iframe src="https://www.youtube.com/embed/{}"
        frameborder="0" allowfullscreen></iframe>"#,
        id
    )
}

#[alloy_shortcode("callout")]
fn callout(args: Vec<&str>, content: &str) -> String {
    let level = args[0];
    format!(r#"<div class="callout callout--{}">{}</div>"#, level, content)
}

TinyGo:

// plugins/shortcodes.go (compile with TinyGo)
package main

import "fmt"

//export register
func register(alloy *Alloy) {
    alloy.Shortcode("youtube", func(args []string) string {
        return fmt.Sprintf(
            `<iframe src="https://www.youtube.com/embed/%s"
            frameborder="0" allowfullscreen></iframe>`,
            args[0],
        )
    })

    alloy.Shortcode("callout", func(args []string, content string) string {
        return fmt.Sprintf(
            `<div class="callout callout--%s">%s</div>`,
            args[0], content,
        )
    })
}

JS plugins run at ~10-50 microseconds per call. Compiled WASM runs at ~1-10 microseconds per call. Choose based on whether you need the simplicity of plain JS or the performance of compiled code.

Accessing site data

Shortcode plugins can access global data from data/ files via alloy.data:

// plugins/status-tag.js
export default function(alloy) {
  alloy.shortcode("statusTag", (args) => {
    const key = args[0];
    const legend = alloy.data.statusLegend;  // from data/statusLegend.yaml
    const entry = legend[key];
    return `<rh-tag color="${entry.color}" icon="${entry.icon}">${entry.pretty}</rh-tag>`;
  });
}
{% statusTag "beta" %}

alloy.data is a read-only snapshot of site.data injected after data files are loaded. Access it inside shortcode functions, not at the top level of the plugin file – top-level access during evaluation returns undefined.

Practical examples

Responsive image shortcode

// plugins/responsive-image.js
export default function(alloy) {
  alloy.shortcode("image", (args) => {
    const src = args[0];
    const alt = args[1] || "";
    return `
      <figure>
        <img src="${src}" alt="${alt}" loading="lazy" decoding="async">
        ${alt ? `<figcaption>${alt}</figcaption>` : ""}
      </figure>`;
  });
}
{% image "/img/hero.jpg" "A sunset over the mountains" %}

Admonition block shortcode

// plugins/admonition.js
export default function(alloy) {
  alloy.shortcode("note", (args, content) => {
    return `<div class="admonition admonition--note">
      <p class="admonition-title">Note</p>
      <div>${content}</div>
    </div>`;
  });

  alloy.shortcode("tip", (args, content) => {
    return `<div class="admonition admonition--tip">
      <p class="admonition-title">Tip</p>
      <div>${content}</div>
    </div>`;
  });
}
{% note %}
  Remember to run `alloy build` before deploying.
{% endnote %}

{% tip %}
  Use `alloy dev` during local development for live reloading.
{% endtip %}

Name conflicts

If two plugins register the same shortcode name, the last one loaded wins. Plugins load in alphabetical filename order within plugins/: built-in Go functions first, then Tier 2 (.js and .wasm), then Tier 3 (.js with runtime: "node").

Alloy logs a warning when a name collision occurs so you know which plugin took precedence.