Build a complete shortcode system from scratch — parser architecture, creating gallery/youtube/callout/accordion shortcodes, nested shortcode support, parameter handling with type validation, and XSS prevention in shortcode output. Includes production-ready PHP code.
Why Shortcodes Still Matter
Block editors get all the attention these days, but shortcodes remain the fastest way to embed dynamic content inside post bodies. When a content editor writes [gallery ids="12,15,18" columns="3"] in the post editor, the system replaces it with a fully rendered image grid at display time. No JavaScript widget, no iframe, no third-party embed — just server-rendered HTML that loads instantly.
We added shortcode support to JekCMS in version 1.2 after three clients asked for the same thing: a way to embed photo galleries, YouTube videos, and callout boxes inside their blog posts without touching HTML. The implementation took two days for the core parser and another day for the built-in shortcodes. Here is exactly how we built it.
The Parser Architecture
A shortcode parser needs to handle four cases:
- Self-closing:
[youtube id="dQw4w9WgXcQ"] - With content:
[callout type="warning"]Watch out for this.[/callout] - Nested:
[accordion][section title="FAQ 1"]Answer here[/section][/accordion] - No parameters:
[divider]
The regex that handles all four is surprisingly compact:
class ShortcodeParser {
private array $registered = [];
public function register(string $tag, callable $handler): void {
$this->registered[$tag] = $handler;
}
public function parse(string $content): string {
if (strpos($content, '[') === false) {
return $content; // Fast path: no shortcodes possible
}
$tags = implode('|', array_map('preg_quote', array_keys($this->registered)));
if (empty($tags)) return $content;
// Match shortcodes with content: [tag attrs]content[/tag]
$pattern = '/[(' . $tags . ')([^]]*)](.*?)[/1]/s';
$content = preg_replace_callback($pattern, function($m) {
return $this->execute($m[1], $this->parseAttributes($m[2]), $m[3]);
}, $content);
// Match self-closing shortcodes: [tag attrs] or [tag]
$pattern = '/[(' . $tags . ')([^]]*?)/?]/s';
$content = preg_replace_callback($pattern, function($m) {
return $this->execute($m[1], $this->parseAttributes($m[2]), '');
}, $content);
return $content;
}
private function execute(string $tag, array $attrs, string $content): string {
if (!isset($this->registered[$tag])) return '';
return call_user_func($this->registered[$tag], $attrs, $content, $tag);
}
private function parseAttributes(string $raw): array {
$attrs = [];
$raw = trim($raw);
if (empty($raw)) return $attrs;
// Match key="value" or key='value' or key=value
preg_match_all(
'/(w+)s*=s*(?:"([^"]*)"|''([^'']*)''|(S+))/',
$raw,
$matches,
PREG_SET_ORDER
);
foreach ($matches as $match) {
$key = $match[1];
$value = $match[2] !== '' ? $match[2] : ($match[3] !== '' ? $match[3] : $match[4]);
$attrs[$key] = $value;
}
return $attrs;
}
}
The fast path on line 9 is worth highlighting. Before running any regex, we check if the content even contains a [ character. For posts without shortcodes (which is most posts), this avoids the regex engine entirely. On a blog with 2,000 posts, this optimization saved about 15ms per page load in aggregate because the shortcode parser runs on every post display.
Building the Gallery Shortcode
The gallery shortcode is the most requested feature. It takes a comma-separated list of media IDs and renders them as a responsive grid with lightbox support.
Usage: [gallery ids="12,15,18,22" columns="3" size="medium"]
$parser->register('gallery', function(array $attrs, string $content) {
// Parameter defaults
$ids = array_filter(array_map('intval', explode(',', $attrs['ids'] ?? '')));
$columns = max(1, min(6, (int)($attrs['columns'] ?? 3)));
$size = in_array($attrs['size'] ?? 'medium', ['thumbnail', 'medium', 'large'])
? $attrs['size']
: 'medium';
if (empty($ids)) return '<!-- gallery: no valid ids -->';
// Fetch media records
global $db;
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$images = $db->fetchAll(
"SELECT * FROM media WHERE id IN ({$placeholders}) ORDER BY FIELD(id, {$placeholders})",
array_merge($ids, $ids)
);
if (empty($images)) return '<!-- gallery: no images found -->';
$html = '<div class="shortcode-gallery" style="--gallery-cols: ' . $columns . '">';
foreach ($images as $img) {
$src = UPLOADS_URL . '/' . $img['path'];
$thumb = get_thumbnail_path($img['path'], $size);
$alt = htmlspecialchars($img['alt_text'] ?? $img['filename'], ENT_QUOTES, 'UTF-8');
$html .= '<figure class="gallery-item">';
$html .= '<a href="' . $src . '" data-lightbox="shortcode-gallery">';
$html .= '<img src="' . $thumb . '" alt="' . $alt . '" ';
$html .= 'width="400" height="300" loading="lazy">';
$html .= '</a>';
if (!empty($img['caption'])) {
$html .= '<figcaption>' . htmlspecialchars($img['caption']) . '</figcaption>';
}
$html .= '</figure>';
}
$html .= '</div>';
return $html;
});
The CSS uses CSS Grid with a custom property for column count:
.shortcode-gallery {
display: grid;
grid-template-columns: repeat(var(--gallery-cols, 3), 1fr);
gap: 0.5rem;
margin: 2rem 0;
}
@media (max-width: 768px) {
.shortcode-gallery {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 480px) {
.shortcode-gallery {
grid-template-columns: 1fr;
}
}
.shortcode-gallery .gallery-item {
margin: 0;
}
.shortcode-gallery .gallery-item img {
width: 100%;
height: 100%;
object-fit: cover;
aspect-ratio: 4 / 3;
}
Notice we pass the column count as a CSS custom property (--gallery-cols) instead of generating separate CSS classes for each column count. This means one CSS rule handles any column count from 1 to 6.
Building the YouTube Shortcode
YouTube embeds are performance killers if done carelessly. Each iframe loads about 600KB of JavaScript. Our shortcode uses the facade pattern — show a thumbnail with a play button, and only load the iframe when the user clicks.
Usage: [youtube id="dQw4w9WgXcQ" title="Video title here"]
$parser->register('youtube', function(array $attrs, string $content) {
$id = preg_replace('/[^a-zA-Z0-9_-]/', '', $attrs['id'] ?? '');
if (empty($id)) return '<!-- youtube: no valid id -->';
$title = htmlspecialchars($attrs['title'] ?? 'Video', ENT_QUOTES, 'UTF-8');
// Use YouTube thumbnail as poster
$thumb = "https://i.ytimg.com/vi/{$id}/hqdefault.jpg";
return '<div class="youtube-facade" data-id="' . $id . '">
<img src="' . $thumb . '" alt="' . $title . '" width="480" height="360" loading="lazy">
<button class="play-btn" aria-label="Play ' . $title . '">
<svg viewBox="0 0 68 48" width="68" height="48">
<path d="M66.52 7.74c-.78-2.93-2.49-5.41-5.42-6.19C55.79.13 34 0 34 0S12.21.13 6.9 1.55C3.97 2.33 2.27 4.81 1.48 7.74.06 13.05 0 24 0 24s.06 10.95 1.48 16.26c.78 2.93 2.49 5.41 5.42 6.19C12.21 47.87 34 48 34 48s21.79-.13 27.1-1.55c2.93-.78 4.64-3.26 5.42-6.19C67.94 34.95 68 24 68 24s-.06-10.95-1.48-16.26z" fill="#212121" fill-opacity=".8"/>
<path d="M45 24L27 14v20" fill="#fff"/>
</svg>
</button>
</div>';
});
The JavaScript to activate the facade is minimal:
document.addEventListener('click', function(e) {
const facade = e.target.closest('.youtube-facade');
if (!facade) return;
const id = facade.dataset.id;
facade.innerHTML = '<iframe src="https://www.youtube-nocookie.com/embed/'
+ id + '?autoplay=1&rel=0" frameborder="0" allow="autoplay; encrypted-media" '
+ 'allowfullscreen style="position:absolute;top:0;left:0;width:100%;height:100%">'
+ '</iframe>';
facade.classList.add('activated');
});
Two security details: we use youtube-nocookie.com instead of youtube.com to avoid tracking cookies until the user explicitly clicks play. And we strip any non-alphanumeric characters from the video ID using the regex on line 2 of the handler — this prevents XSS through crafted shortcode parameters.
Building the Callout Shortcode
Callout boxes highlight important information within a post. They support four types: info, warning, success, and error.
Usage: [callout type="warning"]Be careful with this step. Data loss is possible.[/callout]
$parser->register('callout', function(array $attrs, string $content) {
$validTypes = ['info', 'warning', 'success', 'error'];
$type = in_array($attrs['type'] ?? 'info', $validTypes)
? $attrs['type']
: 'info';
$title = htmlspecialchars($attrs['title'] ?? '', ENT_QUOTES, 'UTF-8');
$sanitizedContent = wp_kses_post_equivalent($content);
$icons = [
'info' => '<svg viewBox="0 0 24 24" width="20" height="20"><circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2"/><path d="M12 16v-4M12 8h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>',
'warning' => '<svg viewBox="0 0 24 24" width="20" height="20"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" fill="none" stroke="currentColor" stroke-width="2"/><path d="M12 9v4M12 17h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>',
'success' => '<svg viewBox="0 0 24 24" width="20" height="20"><path d="M22 11.08V12a10 10 0 11-5.93-9.14" fill="none" stroke="currentColor" stroke-width="2"/><path d="M22 4L12 14.01l-3-3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>',
'error' => '<svg viewBox="0 0 24 24" width="20" height="20"><circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2"/><path d="M15 9l-6 6M9 9l6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>',
];
$html = '<div class="callout callout--' . $type . '" role="alert">';
$html .= '<div class="callout-icon">' . $icons[$type] . '</div>';
$html .= '<div class="callout-body">';
if ($title) {
$html .= '<strong class="callout-title">' . $title . '</strong>';
}
$html .= '<div class="callout-content">' . $sanitizedContent . '</div>';
$html .= '</div></div>';
return $html;
});
The role="alert" attribute ensures screen readers announce the callout content, making it accessible.
Building the Accordion Shortcode
Accordions use a parent-child structure with nested shortcodes. This is the most complex pattern because the parser needs to handle the outer [accordion] tag and the inner [section] tags in the right order.
Usage:
[accordion]
[section title="What payment methods do you accept?"]
We accept credit cards, bank transfers, and PayPal.
[/section]
[section title="How long does delivery take?"]
Standard delivery is 3-5 business days.
[/section]
[/accordion]
The implementation registers both the parent and child shortcodes:
// Inner shortcode: [section title="..."]content[/section]
$parser->register('section', function(array $attrs, string $content) {
$title = htmlspecialchars($attrs['title'] ?? 'Section', ENT_QUOTES, 'UTF-8');
$id = 'acc-' . substr(md5($title . $content), 0, 8);
return '<details class="accordion-item">
<summary class="accordion-header">' . $title . '</summary>
<div class="accordion-body">' . $content . '</div>
</details>';
});
// Outer shortcode: [accordion]...[/accordion]
$parser->register('accordion', function(array $attrs, string $content, string $tag) {
global $shortcodeParser;
// Parse inner shortcodes first
$innerHtml = $shortcodeParser->parse($content);
$singleOpen = isset($attrs['single']) && $attrs['single'] === 'true';
$exclusive = $singleOpen ? ' data-exclusive="true"' : '';
return '<div class="accordion"' . $exclusive . '>' . $innerHtml . '</div>';
});
We use the native HTML <details> and <summary> elements instead of custom JavaScript. This gives us built-in keyboard accessibility and works without JavaScript. The only JavaScript needed is for the optional "single open" mode where opening one section closes the others:
document.querySelectorAll('.accordion[data-exclusive]').forEach(accordion => {
accordion.addEventListener('toggle', function(e) {
if (e.target.open) {
accordion.querySelectorAll('details[open]').forEach(detail => {
if (detail !== e.target) detail.open = false;
});
}
}, true);
});
Handling Nested Shortcodes
The parser processes shortcodes from the inside out. When it encounters [accordion][section]...[/section][/accordion], it first processes the [section] tags (because they are matched by the content-bearing regex first), and then processes [accordion] with the already-rendered section HTML.
This works naturally for most cases, but breaks when the inner shortcode name appears in the regex pattern before the outer one. The solution is to process the content-bearing pattern (with closing tags) before the self-closing pattern. Since [section]...[/section] is content-bearing, it gets matched and replaced first, and by the time the parser reaches [accordion]...[/accordion], the inner content is already HTML.
Security: XSS Prevention in Shortcode Output
Shortcodes are a potential XSS vector because they accept user-provided parameters. If you render parameters directly into HTML without escaping, an attacker can inject script tags through crafted shortcode attributes.
Consider this malicious input:
[callout type="info" onmouseover="alert(1)"]Content[/callout]
If you render the type attribute directly into a class name without validation, the onmouseover event handler gets injected into the HTML. Our defense has three layers:
- Whitelist validation: For enumerated parameters like
type, only accept known values. The callout handler checks against['info', 'warning', 'success', 'error']and defaults toinfofor anything else. - Type casting: For numeric parameters like
columns, cast to integer immediately:(int)($attrs['columns'] ?? 3). This eliminates any string-based injection. - Output escaping: For free-text parameters like
title, always usehtmlspecialchars($value, ENT_QUOTES, 'UTF-8'). TheENT_QUOTESflag ensures both single and double quotes are escaped, preventing attribute breakout.
The content body (text between opening and closing tags) needs its own sanitization. We strip all HTML tags except a whitelist of safe elements: <p>, <br>, <strong>, <em>, <a>, <code>, <ul>, <ol>, <li>. For <a> tags, we only allow the href attribute and validate that it starts with http://, https://, or / — no javascript: URLs.
Registration and Usage
All shortcodes are registered in a single file that loads during theme initialization:
// includes/shortcodes.php
$shortcodeParser = new ShortcodeParser();
// Register all built-in shortcodes
require_once __DIR__ . '/shortcodes/gallery.php';
require_once __DIR__ . '/shortcodes/youtube.php';
require_once __DIR__ . '/shortcodes/callout.php';
require_once __DIR__ . '/shortcodes/accordion.php';
// Hook into content display
function process_shortcodes(string $content): string {
global $shortcodeParser;
return $shortcodeParser->parse($content);
}
In the template, shortcodes are processed during post rendering:
<article class="post-content">
<?= process_shortcodes($post['content']) ?>
</article>
Creating a custom shortcode for your site takes about 15 minutes: write the handler function, register it with the parser, and add the CSS. The parser handles all the regex matching, attribute parsing, and nesting — your handler just receives clean arrays and returns HTML.