Alpine.js directives and WordPress sanitization

WordPress has functions with sensible defaults for when you want to filter untrusted HTML. To extend the default allowed list, you can use the wp_kses_allowed_html filter.

Mostly I see people reaching for this when dealing with complex SVG structures where lesser-known tags and attributes pop up.

Another scenario is when working with JavaScript libraries using directives, such as Alpine.js or Vue.js.

The problem with directives and WordPress

By default, if you run trough wp_kses_post an HTML containing Alpine.js directives, all are stripped out.

$alpineJsHtml = <<<'EOD'
    <div x-data="{ isVisible: false }">
        <button type="button"
            x-on:click.debounce.250="isVisible = !isVisible">
            Toggle with debounce
        </button>
        <p x-show="isVisible">I'm visible</p>
    </div>
EOD;

echo wp_kses_post($alpineJsHtml);

The output:

<div>
    <button type="button">
        Toggle with debounce
    </button>
    <p>I'm visible</p>
</div>

Behind the scenes, WordPress determines if an attribute is allowed using the wp_kses_attr_check function. Besides the data-* wildcard attribute, all other attributes have to be exactly predefined, and unfortunately, there is no filter attached to the function to pass custom conditions.

Alpine.js (2.6.0) has 14 directives, and most of them don't have a dynamic part or modifiers. Allowing them could look something like this:


add_filter(
    'wp_kses_allowed_html',
    static function ($tags) {
        $alpinizedTags = ['div', 'section', 'template'];
        $alpineDirectives = [
            'x-cloak' => true,
            'x-data' => true,
            'x-if' => true,
            // ...
        ];

        foreach ($alpinizedTags as $alpinizedTag) {
            if (!isset($tags[$alpinizedTag])) {
                continue;
            }

            $tags[$alpinizedTag] = array_merge($alpineDirectives, $tags[$alpinizedTag]);
        }

        return $tags;
    }
);

Dynamic directives and modifiers

But with directives like x-on where you can listen for custom events, and add modifiers that represent milliseconds, there is no sane way to predefine all possible variations. Remember, the allowed attribute has to be an exact match; no wildcards are supported.

One option is to simply update the list whenever you use a new combination.

$alpineDirectives = [
    'x-on' => true, // not enough
    'x-on:click.debounce.250' => true,
    'x-on:change' => true,
    'x-on:open-menu' => 'true',
    // ...
];

Another solution is to deal with directives case by case basis instead of relying on a global list. This is how it would look with the wp_kses function:

echo wp_kses($alpineJsHtml, [
    'div' => [
        'x-data' => true,
    ],
    'button' => [
        'type' => true,
        'x-on:click.debounce.250' => true,
    ]
]);

But we can take this idea further and construct the list of directives dynamically and allowing them automatically.

function allowedPostTagsWithAlpineAttrs($content)
{
    global $allowedposttags;

    // Anything that looks like an Alpine.js directive
    preg_match_all('/(x-[\w:.-]*)/', $content, $matches);

    if ($matches === false || empty($matches[0])) {
        return $allowedposttags;
    }

    $allowedTags = $allowedposttags;
    $alpineAttrs = [];

    foreach ($matches[0] as $match) {
        $alpineAttrs[$match] = true;
    }

    foreach ($allowedTags as $tag => $attributes) {
        $allowedTags[$tag] = array_merge($alpineAttrs, $attributes);
    }

    return $allowedTags;
}

echo wp_kses($alpineJsHtml, allowedPostTagsWithAlpineAttrs($alpineJsHtml));

With this approach, we don't have to keep track of the modifiers or update the directives list if Alpine.js introduces a new one.