Appearance
Shortcodes
Introduction
Content imported from WordPress is full of shortcodes: [contact-form-7 id="…"], [products limit="4"], [gallery ids="12,14"]. kitloom/shortcodes reads them with the same syntax WordPress does — [tag], [tag attr="value"], [tag /], [tag]content[/tag], [[tag]] for a literal tag — and hands each to the handler registered for its tag. A tag nobody registered stays in the text as it is, as in WordPress.
A handler does one of two things:
- It leaves the drawing to the theme. A handler built on
AbstractShortcodeputs an anchor in the text and the shortcode's data into the page, so the theme draws it with its own markup: a Vue component or a Blade view. Forms, products and sliders work this way. - It prints markup. A handler that implements
ShortcodeHandlerdirectly, or a closure, returns HTML that goes into the text as it is. WordPress's media shortcodes and the widgets work this way: their markup is WordPress's, so the theme's old CSS still applies.
From content to the page
- Content filter. Post content, term descriptions and widget text go through the site's content filters. The
shortcodesfilter replaces each registered shortcode with its handler's output. - Anchor and data. A handler built on
AbstractShortcodereturns<div data-shortcode="shortcode_products_…"></div>and stores its data under the same key inShortcodeOutput, a store that lives for one request. - Page part. Everything in the store becomes the
shortcodespart of the page:{ key: data }. The part asks for the content and widgets first, so their shortcodes have run by then. - Theme. The theme finds each anchor in the HTML and draws the data stored under its key in its place.
The data of every such shortcode holds:
| Key | Contents |
|---|---|
shortcode | The tag: products. The theme picks its component or view by it. |
arguments | The attributes the handler knows, with defaults filled in. |
content | The text between the opening and closing tags, with its own shortcodes already applied. Empty for a single tag. |
The rest is whatever the handler returns: items, slider, contactForm…
A shortcode that fails doesn't take the page down. The exception is reported, and the anchor stays without data, so the theme draws nothing in its place.
Writing a shortcode
Extend AbstractShortcode. In defaults(), list the attributes the shortcode knows and their default values; attributes not listed there are dropped. In data(), return what the theme needs to draw it:
php
<?php
declare(strict_types=1);
namespace Acme\Books\Shortcodes;
use Acme\Books\BookCatalog;
use Kitloom\Shortcodes\AbstractShortcode;
use Kitloom\Shortcodes\Attributes;
/**
* `[books genre="poetry,drama" limit="6" featured="yes"]` — a row of books.
*/
final class BooksShortcode extends AbstractShortcode
{
public function __construct(private readonly BookCatalog $books) {}
public function defaults(): array
{
return ['genre' => '', 'limit' => '6', 'featured' => '', 'title' => ''];
}
protected function data(Attributes $attributes, string $content, string $tag): array
{
return [
'items' => $this->books->latest(
genres: $attributes->list('genre'),
limit: $attributes->int('limit', 6),
featured: $attributes->bool('featured'),
),
];
}
}Content stores every attribute as text. Attributes reads it as the type you need:
| Method | limit="6", featured="yes"… | Notes |
|---|---|---|
string($name, $default = '') | '6' | trimmed |
int($name, $default = 0) | 6 | an empty or non-numeric value gives the default |
bool($name, $default = false) | true | 1, true, yes, on; an empty value gives the default |
list($name) | ['poetry', 'drama'] | comma-separated, blanks dropped |
ids($name) | [3, 7] | comma-separated positive integers |
get($name), all(), has($name) | raw values | |
with($name, $value) | a copy with one value replaced |
To hand the theme a corrected attribute (an unknown layout turned into grid), return your own arguments. It replaces the default one:
php
return [
'arguments' => $attributes->with('layout', $layout)->all(),
'items' => $items,
];$content is the text between the tags as written. The theme gets it in content with nested shortcodes already applied.
Printing markup
A shortcode whose markup is fixed — WordPress's own, or one that only works with a script of the module — implements Kitloom\Shortcodes\Contracts\ShortcodeHandler: defaults() and __invoke(array $attributes, string $content, string $tag): string. Read the attributes with Shortcodes::shortcodeAtts($this->defaults(), $attributes, $tag), as WordPress's shortcode_atts(). Its output goes into the text as it is, with no anchor and no data.
A shortcode that only returns a string needs no class:
php
$shortcodes->addShortcode('year', fn (array $attributes, string $content, string $tag): string => date('Y'));Keep nothing on the handler
The registry may hand the same instance every shortcode on the page, the same class can serve several tags, and an instance may outlive the request — under Octane, or in a queue worker warming the page cache. So read everything from the arguments of data() or __invoke(), and don't keep per-request state in properties.
Registering
Register the class by name in your provider's boot(), once the registry is resolved, behind a switch:
php
use Acme\Books\Shortcodes\BooksShortcode;
use Kitloom\Shortcodes\Shortcodes;
public function boot(): void
{
if (! (bool) $this->app['config']->get('acme-books.shortcodes.register', true)) {
return;
}
$this->callAfterResolving(Shortcodes::class, static function (Shortcodes $shortcodes): void {
$shortcodes->addShortcode('books', BooksShortcode::class);
$shortcodes->addShortcode('book_list', BooksShortcode::class); // the old name still works
});
}The container makes the class when the tag is met, so its constructor can ask for services. A name with spaces or any of & / < > [ ] = is refused, and so is a class that is not a ShortcodeHandler.
A later registration of the same tag replaces the earlier one. Give the application a <config>.shortcodes.register switch, so it can replace your handler with its own. The bundled modules have one each:
| Switch | Shortcodes |
|---|---|
kitloom-forms.shortcodes.register | [contact-form-7], [contact-form], form fields [text], [email], [url], [tel], [number], [date], [textarea], [select], [checkbox], [radio], [acceptance], [submit] |
kitloom-shop.shortcodes.register | [products], [product_categories], [woocommerce_cart], [woocommerce_checkout], [woocommerce_my_account] |
kitloom-slider.shortcodes.register | [slide-anything] |
kitloom-seo.shortcodes.register | [rank_math_html_sitemap], [sitemap] |
kitloom-social-accounts.shortcodes.register | [nextend_social_login] |
kitloom-media.shortcodes.register | WordPress's [gallery], [caption] (and [wp_caption]), [audio], [video], [playlist], [embed] — markup |
kitloom-widgets.shortcodes.register | [recent_posts], [recent_comments], [calendar], [categories], [archives], [nav_menu], [search], [tag_cloud], [media_image], [pages], [meta] — the widget with its settings as attributes, markup |
kitloom-languages.shortcodes.register | [language_switcher] — the page's languages, drawn by the theme; also the language switcher widget |
kitloom-cookie-consent.shortcodes.register | [cookie-consent], [cmplz-manage-consent] — a button that opens the consent preferences, markup |
kitloom-shortcodes.widgets.register | [sidebar id="footer"] — a widget zone, [widget id="custom_html-4"] — one widget, markup |
[sidebar] and [widget] come with kitloom/shortcodes itself. A zone or a widget never draws itself, and widgets go at most three deep inside each other.
Drawing it in a theme
Vue
The page gets the shortcodes prop. Split the HTML at the anchors and render a component for each one, chosen by data.shortcode. See RichContent.vue and ShortcodeBlock.vue in kitloom/game-reviews-theme:
js
const pattern = /<div data-shortcode="([^"]+)"><\/div>/g;
// …html before the anchor, then <ShortcodeBlock :data="shortcodes[key]" />A Vue theme lists ShortcodeData (Kitloom\Shortcodes\Site\ShortcodeData) among the parts of the templates that show content. Markup shortcodes need nothing: they are in the HTML already.
Blade
@shortcodes($html) replaces each anchor with the theme's view shortcodes/{tag}, which gets the data as $shortcode. A shortcode with no view leaves nothing behind:
blade
{{-- resources/views/shortcodes/books.blade.php --}}
<section class="books">
@if ($shortcode['arguments']['title'])
<h2>{{ $shortcode['arguments']['title'] }}</h2>
@endif
@foreach ($shortcode['items'] as $book)
@include('partials.book-card', ['book' => $book])
@endforeach
</section>For an enclosing shortcode, render its content with @shortcodes($shortcode['content']) too: the content may hold anchors of its own.
When the visitor talks back
A shortcode only draws; it never reads the request. The same shortcode is drawn for a page, from the console and for the page cache.
When the visitor sends something back — a form, a vote — the module takes it on a route of its own under the site's REST root (a RestNamespace, see Extension Points), and the shortcode's data tells the theme where to send it. kitloom/forms puts action into the data of [contact-form-7]:
php
use Kitloom\Site\Rest\RestApi;
public function __construct(private readonly RestApi $rest) {}
protected function data(Attributes $attributes, string $content, string $tag): array
{
return [
// POST /wp-json/contact-form-7/v1/contact-forms/{id}/feedback
'action' => $this->rest->url('contact-form-7/v1', 'contact-forms/'.$id.'/feedback'),
// …
];
}A list that loads more items reads them from the page itself: GET <archive>/page/2/ with Accept: application/json answers the next page of posts as data.
Not handled
Content brought over from WordPress often uses shortcodes of plugins the site no longer has: [vc_row], [su_button]. Nothing handles them, so visitors see them as text. The Shortcodes screen of either admin (Tools → Shortcodes) lists every registered tag with a copyable example built from defaults(), and ends with a Not handled list: each unhandled tag, how many times it is used, in how many records, and the first records and widgets it is in — so you know what to build or what to take out of the content. It is read each time the screen opens.
What is read: published records of the post types that have pages, and the markup of the widgets in the site's sidebars. Escaped tags ([[book]]), names without a letter ([1]) and closing tags are not counted. The same list in code:
php
use Kitloom\Shortcodes\Content\ShortcodeUsage;
app(ShortcodeUsage::class)->unhandled(samples: 5);
// [['tag' => 'book', 'count' => 4, 'records' => 2, 'posts' => [['id' => 7, 'title' => 'Intro', 'type' => 'post'], …], 'widgets' => […]], …]An attribute gets a description on that screen from the docblock of an ARG_{NAME} constant on the handler:
php
/** Comma-separated genre slugs */
public const ARG_GENRE = 'genre';Switches
| Key | Default | Turns off |
|---|---|---|
kitloom-shortcodes.pipeline.register | true | The content filter, the shortcodes page part and @shortcodes. Use it when the application delivers shortcodes its own way. |
kitloom-shortcodes.widgets.register | true | [sidebar] and [widget] |
kitloom-shortcodes.filament.register | true | Tools → Shortcodes in Filament (kitloom/shortcodes-filament) |
kitloom-shortcodes.nova.register | true | Tools → Shortcodes in Nova (kitloom/shortcodes-nova) |