Markdown in WordPress needs an escaping pass

Blog thumbnail reading Escape First: isometric funnel machine turns crumpled sheets into clean cards on a tray.

WordPress doesn’t parse Markdown. Core gives you wp_kses, wpautop and a block editor, and nothing that turns Markdown into HTML, so whenever a client wants Markdown in WordPress, the rendering lands on a plugin or on code you write. That gap is why a post Zell Liew wrote on CSS-Tricks interested me, and why I wouldn’t copy his snippet into a WordPress template unchanged.

The post shares a small utility from his Splendid Labz library that works in Astro, Svelte, React and Vue. It fixes a specific problem: indented Markdown inside a component renders as a code block, because four spaces of indentation is code in CommonMark. The utility measures the shared indentation, strips it, then parses. I wrote before about replacing the Astro Markdown component and about MDX when the content needs components, so the framework side is covered there. On WordPress, the last line of every example in his post dumps the parsed string into the DOM with set:html or {@html}.

What the indentation fix is worth here

The CommonMark spec says a run of non-blank lines indented four or more spaces, after a blank line, is an indented code block. That’s why prettier-formatted content nested inside a component shows up as monospace text on the first render. The dedent fix is useful whenever Markdown lives inside template files, and I’ve hit that exact annoyance. But it does nothing for the problem WordPress has with Markdown.

On WordPress, Markdown rarely lives in templates. It lives in post meta, ACF text areas, product tabs, order notes, and importer scripts that read a folder of .md files. So the shape of the utility, pass a string and get HTML back, maps neatly onto a template helper. The difference is where the string comes from. In an Astro build the string was written by a developer at build time, so trusting the output is a defensible default. On a store the same string was typed by a shop manager into a field, and Markdown parsers pass raw HTML through untouched: put a script tag in the source and it lands in the output. Markdown is an input format. It decides structure from your text and says nothing about which HTML is allowed.

The WordPress version of the utility needs one more layer, and core already ships it. I wrap the parser in a helper that always ends with a kses pass:

function bbioon_render_markdown( string $markdown, array $args = [] ) {
    $args = wp_parse_args( $args, [
        'inline'  => false,
        'allowed' => [], // empty = post rules
    ] );

    $html = bbioon_md_to_html( $markdown, [ 'inline' => $args['inline'] ] );

    return $args['allowed']
        ? wp_kses( $html, $args['allowed'] )
        : wp_kses_post( $html );
}

bbioon_md_to_html is a thin wrapper around whichever parser you ship through Composer; league/commonmark and Parsedown both work. The wp_kses pass is not optional. wp_kses_post applies the same allowed-tags list the editor enforces, which means an attacker gets the same ceiling an author gets. If the field is a bio or a caption where you only want a few tags, pass an explicit array to allowed and wp_kses strips everything else, attributes included.

One more decision: render on output or on save. Store the Markdown, render on output, and cache the result if the field is read often. Jetpack’s classic-editor Markdown module does the opposite: it saves rendered HTML into post_content and keeps the Markdown source alongside the post, so it can restore your text when you edit again. It works, but it makes post content a derived value, and I’ve seen enough content pasted between sites to prefer keeping the source as the source of truth.

When I would skip Markdown in WordPress entirely

If the request is just “I like writing Markdown in the editor”, the answer is Jetpack’s Markdown block, which runs markdown-it and follows CommonMark. That problem is already solved, and a custom parser doesn’t need to exist for it. A parser earns its keep where content lives outside the editor: ACF fields, product data on WooCommerce stores, docs folders imported into the database, email templates. Those are the places where nobody is watching the input closely.

On single-site installs, administrators and editors hold the unfiltered_html capability, while on multisite only super admins do. If your helper skips kses for “trusted” users, your security model now depends on the install type and on whatever role plugins are doing to it. Always running wp_kses_post costs almost nothing and keeps behavior identical everywhere. (This matters more if the same field also feeds a headless frontend, since that side will sanitize its own way.)

Interop is the last thing to remember. Parsed Markdown output doesn’t run shortcodes or blocks. If a field needs to hold a shortcode, Markdown is the wrong tool for that field, and a plain textarea with the right escaping is the right one.

Run grep -rn "echo \$" wp-content/themes/your-theme/, find every place that prints a meta field or an ACF value straight into a template, and wrap those in wp_kses_post. Most of those fields aren’t Markdown, and they still deserve the pass. Zell’s utility is a good fix for the indentation quirk, but on our side the parse step was never the risky part. If you’ve got Markdown piling up in custom fields or import scripts and want it rendered with proper escaping on a WordPress or WooCommerce site, that’s work I do, from picking the parser to building the allowed-tags list.

author avatar
Ahmad Wael
I'm a WordPress and WooCommerce developer with 15+ years of experience building custom e-commerce solutions and plugins. I specialize in PHP development, following WordPress coding standards to deliver clean, maintainable code. Currently, I'm exploring AI and e-commerce by building multi-agent systems and SaaS products that integrate technologies like Google Gemini API with WordPress platforms, approaching every project with a commitment to performance, security, and exceptional user experience.

Leave a Comment