Wahlberg Docs.
- Craft CMS 5
- PHP 8.2+
composer require bensomething/craft-wahlberg:^1.0.0-beta
The -beta in the constraint is what lets Composer install it under a project’s default stable minimum stability.
Or install from the control panel: Settings → Plugins.
Create a field of type Markdown and add it to a field layout.
Field settings
| Setting | Default | |
|---|---|---|
| Markdown Flavour | GitHub-Flavoured | Which parser the Preview tab and the html value use. GFM adds fenced code blocks, tables, strikethrough and autolinking; Traditional Markdown and Markdown Extra are also available. |
| Preserve Line Breaks | On | GFM only. Turns a single newline into a <br>, the way GitHub’s comment boxes do. Turn it off for Markdown that’s hard-wrapped and meant to reflow. This is the parser’s gfm-comment flavour, which is what .flavour reports. |
| Inline Only | Off | Render without the wrapping <p>, for a heading or strapline going into markup of its own. Emphasis, links and code still parse. |
Appearance
| Setting | Default | |
|---|---|---|
| Text Size | 14px | The Markdown source in the editor, 11–20px. Editing comfort only — no bearing on the front end. |
| Minimum Rows | 2 | How short the editor may get, 1 or more. It grows from there as the author types. |
| Maximum Rows | none | How tall it may grow before it scrolls instead. Blank lets it keep growing. Dragging the resize handle overrides auto-growing for that session. |
| Placeholder Text | none | Shown while the field is empty. |
| Show Preview Tab | On | Off makes the editor source-only, and the toolbar moves to where the tabs were. |
| Show Formatting Toolbar | On | The keyboard shortcuts keep working either way. |
| Toolbar Buttons | all but Heading 1, Heading 3–6 and the guide | Which buttons the toolbar offers — see The toolbar. |
| Show Syntax Highlighting | On | Off leaves a plain textarea with the same sizing, toolbar and Preview tab. The escape hatch if a font stack won’t hold the highlighted layer and the textarea together. |
| Show Stats | Off | Character, word and line counts under the editor, with the field limit alongside when there is one. |
| Field Limit | none | The most characters or bytes of Markdown the field accepts, enforced on save. Counts the source an author types, not the HTML it renders to. Bytes matter once the text stops being ASCII: an emoji is one character and four bytes. |
Parsing
| Setting | Default | |
|---|---|---|
| Parse Reference Tags | On | See Reference tags. |
| Encode HTML | Off | Encode HTML before parsing, so a tag an author types shows up as text. See Raw HTML and purification. |
| Purify HTML | On | See Raw HTML and purification. |
| HTML Purifier Config | Default | Which JSON config in config/htmlpurifier/ to sanitise with. |
Snippets
Only shown when config/wahlberg.php defines any. See Snippets.
| Setting | Default | |
|---|---|---|
| Available Snippets | all | Which of the defined snippets this field’s Snippets button offers. |
Assets
These apply to the toolbar’s Asset button.
| Setting | Default | |
|---|---|---|
| Available Volumes | all | Which volumes the button may pick from. |
| Show unpermitted volumes | Off | Whether to offer volumes the author can’t view. |
| Show unpermitted files | Off | Whether to offer files uploaded by other authors, per Craft’s “View files uploaded by other users” permission. |
Every button is optional, and which ones a field offers is up to Toolbar Buttons:
| Button | Shortcut | What it writes |
|---|---|---|
| Heading 1–Heading 6 | that level exactly, so clicking H3 on an H1 line makes it an H3 | |
| Bold | ⌘B | ** around the selection, or the word under the caret |
| Italic | ⌘I | _ around the selection, or the word under the caret |
| Strikethrough | ~~ around the selection, or the word under the caret |
|
| Quote | > |
|
| Code | ` around a selection on one line, a fence around one spanning several |
|
| Link | ⌘K | [text](url), or [](url) with the caret in the brackets when a URL was selected |
| Entry, Asset | opens Craft’s element selector — see below | |
| Bulleted list, Numbered list | - and 1. , toggling between each other rather than stacking up |
|
| Snippets | ⌘⇧K | blocks of Markdown you define — see Snippets |
| Markdown guide | a syntax cheatsheet, in a popover off the button |
Shortcuts work whether or not the button is shown, so a field with the toolbar switched off still has all of them. On Windows and Linux, Ctrl stands in for ⌘.
The buttons fold into a menu when the field is too narrow to hold them all. Snippets and Markdown guide are the exceptions: each opens a panel rather than writing anything, so they stay put at the end of the toolbar.
However many heading levels you tick, the toolbar shows one control — six near-identical H icons in a row is a lot of toolbar to say one thing. What changes is its shape:
| Levels ticked | What authors get |
|---|---|
| None | no heading control at all |
| One | a button that applies that level outright |
| Two or more | a dropdown listing them |
The icon is the same plain H either way, with the level named in the tooltip, so the toolbar doesn’t shift about between fields. New fields start with Heading 2 on its own: level 1 is nearly always the element’s own title, so body content starts below it.
Both open Craft’s element selector. Entry writes a link; Asset writes an image as  and anything else as a link, taking the alt text from the asset when it has some.
With Parse Reference Tags on, both write a reference tag rather than a URL:
[The Difference Engine]({entry:19:url})

so the link survives a slug change, or follows the file if it’s replaced or moved. With reference tags off there’s nothing to resolve the tag later, so they write the URL instead — and an entry with no URL of its own writes an empty one.
Blocks of Markdown authors can drop in from the toolbar, defined in config/wahlberg.php. Copy src/config.php to start from a working example.
return [
'snippets' => [
'callout' => [
'label' => 'Callout',
'icon' => 'circle-info',
'body' => "> **Note**\n> \$0\n",
],
// Shorthand: a body on its own, labelled from its key
'leadIn' => "**\$SELECTION**\n\n\$0",
],
];
icon is optional — any name from Craft’s set, which is Font Awesome’s solid icons. One that doesn’t name an icon gets a neutral stand-in, so the labels line up either way.
Two markers are understood, both optional:
$0is where the caret ends up. Without one it lands at the end.$SELECTIONis replaced by whatever the author had selected, so a snippet can wrap their text rather than only ever landing beside it. It’s empty when nothing was selected, and every occurrence is replaced.
Mind the quoting: inside a double-quoted PHP string, $0 and $SELECTION read as variables, so escape them as \$0 and \$SELECTION. Single quotes avoid that but cost you \n. Heredocs interpolate; nowdocs (<<<'MD') don’t.
Why a config file and not a settings screen? A snippet is a contract with the templates and CSS that render it — a callout only looks like a callout because your front end styles what it emits. So the person writing one should be the person who can also write that, and the definition should travel with the code in version control. Which snippets a given field offers is a field setting, under Available Snippets — the same split config/htmlpurifier/ already uses.
A field that has never been saved against a snippet offers all of them, so adding one to the config file reaches every existing field without editing each one. With no config file the Snippets button hides itself rather than opening an empty menu, and the field settings drop the section to match.
⌘⇧K opens it at the caret, which is where the snippet is going. Clicking the toolbar button opens it under the button instead, since that's where the eye already is.
The shortcut doesn't need the button: it works with Snippets unticked in Toolbar Buttons, and with the toolbar switched off altogether. Arrows and Tab move through the list, Enter inserts, Esc closes and puts the caret back.
Mind what your fields render. With Purify HTML on — the default — raw HTML in a snippet is sanitised on the way out, and HTML Purifier only knows HTML 4: <details> and <summary> are dropped entirely, and iframes survive only for the hosts config/htmlpurifier/ allows. Markdown inside a raw HTML block isn’t parsed either, whatever the purifier does. A snippet that emits Markdown works everywhere; one that emits HTML is worth checking in the Preview tab first.
Plugins can add snippets to the pool every field picks from:
use bensomething\wahlberg\events\RegisterSnippetsEvent;
use bensomething\wahlberg\helpers\Snippets;
use yii\base\Event;
Event::on(
Snippets::class,
Snippets::EVENT_REGISTER_SNIPPETS,
function(RegisterSnippetsEvent $event) {
$event->snippets['productSpec'] = [
'label' => Craft::t('my-plugin', 'Product spec'),
'body' => "{spec:\$0}\n",
];
}
);
A handle already defined in config/wahlberg.php wins, so an installation can always overrule a plugin about its own site. Plugins rendering the editor directly can skip the pool entirely and pass definitions to Editor::inputHtml() instead — see Using the editor in your own plugin.
The field value is null, or a MarkdownData object:
{{ entry.body.html }} {# the parsed HTML #}
{{ entry.body.raw }} {# the raw Markdown, as typed #}
{{ entry.body.text }} {# parsed, then stripped to plain text #}
{{ entry.body.flavour }} {# the flavour it was parsed with #}
{{ entry.body }} on its own outputs the raw Markdown, so Craft’s own filter still works if you’d rather parse it yourself:
{{ entry.body|md('gfm') }}
{{ entry.body|md(inlineOnly=true) }}
For Markdown that isn’t in a Markdown field, whether a plain text field, a plugin setting, or a string you built in the template, |marky parses it the way the field does: reference tags resolved, HTML purified.
{{ entry.summary|marky }}
{{ entry.summary|marky(flavour='original') }}
{{ entry.summary|marky(refs=false, purify=false) }}
Arguments are flavour, refs, purify, purifierConfig and siteId, all optional.
Craft’s |md is untouched and still the right choice when plain Markdown parsing is all you want. The difference is what each one does beyond parsing:
|md |
|marky |
.html |
|
|---|---|---|---|
| Parses Markdown | ✅ | ✅ | ✅ |
| Resolves reference tags | ❌ | ✅ | ✅ |
| Purifies HTML | ❌ | ✅ | ✅ |
| Uses the field’s settings | ❌ | only when piped a field value | ✅ |
Piping a field value, {{ entry.body|marky }}, is the same as {{ entry.body.html }}, since it takes the field’s own settings. It’s worth doing only to override one of them for a single render:
{{ entry.body|marky(refs=false) }}
Empty fields are null, so the usual guard applies:
{% if entry.body %}
{{ entry.body.html }}
{% endif %}
Craft’s reference tags work in Markdown fields, and are resolved when the field renders. Parse Reference Tags is on by default.
[Read the docs]({entry:123:url}), see also {entry:my-section/some-entry:title}.

They’re resolved on the way out, not on save, so an entry that changes its slug doesn’t leave a trail of dead links behind it. An unresolvable tag falls back to whatever Craft’s fallback syntax says, or to the tag itself:
{entry:999:title || Something else}
Two things worth knowing:
- Code is left alone. A reference tag in a fenced block or an inline code span renders as the author typed it, which is what you want when the thing you’re documenting is reference tags. This works because tags are resolved after parsing, when the parser has already decided what counts as code, so there’s no second guess at Markdown’s fence rules to get wrong.
- Resolved values are purified. Whatever a tag resolves to goes through HTML Purifier along with the rest of the content, assuming Purify HTML is on.
On a multi-site install, tags resolve against the site the element is being rendered in. Override that per tag with Craft’s own @ syntax, {entry:123@german:url}, or for a whole render with {{ text|marky(siteId=2) }}.
Markdown lets authors write HTML inline, so a Markdown field is an HTML field wearing a disguise. Purify HTML is on by default: entry.body.html is run through HTML Purifier after parsing, using the same defaults as Craft’s own HTML fields, which means YouTube and Vimeo iframes survive and <script> doesn’t.
Two things to know about how that works here:
- It runs at output, not on save. Craft’s CKEditor field purifies the value as it’s stored, because what’s stored is HTML. Here the stored value is Markdown source, and purifying source would mangle it, since autolinks like
<https://example.com>and<inside code fences are not markup. So purification happens each time.htmlis rendered, and the raw Markdown is never touched. |mdbypasses it.{{ entry.body.html }}is purified.{{ entry.body|md }}runs Craft's filter over the raw value and isn’t. That’s deliberate, since.rawhas to stay pristine, but it means the protection lives on one particular path.|markyis on that path,|mdisn’t.
To change what’s allowed through, drop a JSON config file in config/htmlpurifier/ and select it in the field’s settings, exactly as you would for a CKEditor field:
{
"HTML.SafeIframe": true,
"URI.SafeIframeRegexp": "%^(https?:)?//(www\\.youtube\\.com/embed/|player\\.vimeo\\.com/video/|maps\\.google\\.com/)%"
}
Turning Purify HTML off renders exactly what authors type, scripts included. Reasonable when the only people editing are the ones who could edit templates anyway.
Encode HTML is the stricter option, and a different one. Purifying parses the HTML and then drops what isn’t safe; encoding never lets it be HTML at all. An author who types <em>tag</em> gets those characters back on the page rather than an emphasis, and a <script> shows up as text.
Use it for fields where HTML has no business being — a strapline, a caption, a field authored by people you’d rather not hand an <iframe> to. Markdown itself carries on working: **bold** is still bold, it’s only the raw HTML that goes.
Encoding forces Craft’s pre-encoded parser, which is Traditional Markdown with the escaping it would otherwise do inside code taken out. Without that, a fenced block would come back showing &lt; where the author typed <. The flavour selector is disabled while Encode HTML is on for that reason, and .flavour reports pre-encoded.
The two settings are independent, and belt-and-braces is fine: encoding removes the HTML, purifying then sanitises whatever the parser itself produced.
The editor isn’t tied to the field type. Install Wahlberg as a dependency and you can put it on any textarea in the control panel.
From a template. This wraps it in Craft’s own field chrome, so label, instructions, errors and the required marker all behave as they would for any other field:
{% import 'wahlberg/editor' as wahlberg %}
{{ wahlberg.field({
label: 'Release Notes'|t('my-plugin'),
instructions: 'Markdown, please.'|t('my-plugin'),
name: 'notes',
value: settings.notes,
errors: settings.getErrors('notes'),
}) }}
wahlberg.input({ ... }) gives you the bare editor without the field chrome, and Editor::inputHtml([ ... ]) is the same thing from PHP:
use bensomething\wahlberg\Editor;
echo Editor::inputHtml([
'name' => 'notes',
'value' => $model->notes,
]);
Options: name, value, id, toolbar, buttons, preview, highlight, stats, flavour, fontSize, minRows, maxRows, placeholder, charLimit, byteLimit, refTags, assetSources, assetCriteria, snippets, and inputAttributes (merged onto the <textarea>). Anything else in the config is passed through to Craft’s field macro.
snippets takes handles from config/wahlberg.php, or * for all of them. Pass a map of handle => {label, body} instead and the editor uses those directly, for a plugin shipping snippets of its own rather than borrowing the installation’s.
The ones you leave out fall back to the same defaults a Markdown field starts with: 14px text, a 2-row minimum, no maximum, and the same toolbar. An editor rendered from another plugin matches one in a field layout without having to be configured to.
buttons takes the command names Editor::commands() lists, in any order — the toolbar keeps its own, and drops a group nothing was picked from rather than leaving its divider hanging:
{{ wahlberg.field({
label: 'Notes'|t('my-plugin'),
name: 'notes',
value: settings.notes,
buttons: ['bold', 'italic', 'link'],
}) }}
charLimit and byteLimit only draw the counter, and only when stats is on. Enforcing them is the field type’s job, so validate the value yourself out here.
Turn highlight off and you get a plain textarea with the same chrome, sizing and toolbar included, but no Markdown colouring:
{{ wahlberg.field({
label: 'Notes'|t('my-plugin'),
name: 'notes',
value: settings.notes,
highlight: false,
preview: false,
toolbar: false,
}) }}
The Preview tab works without a field behind it. It parses with whatever flavour you pass and always purifies, since there are no field settings to consult. There’s no Preserve Line Breaks option out here: flavour takes a parser flavour directly, so pass gfm to turn line breaks off and gfm-comment (the default) to keep them.
What’s public API here is the three entry points and their options. The markup they generate, the CSS class names, and the data attributes the JS binds to are all internal and will change without a major version, so render through these rather than hand-rolling the HTML.
PreviewController::EVENT_MODIFY_PREVIEW hands you the preview’s HTML after parsing, reference tags and purification — so a listener can add markup the purifier would otherwise strip, inline SVG being the case it exists for. ReferenceTags::outsideCode() is there if you want to leave tokens inside code fences as the author typed them.
Two things to be deliberate about, both because it runs after the sanitiser: what you add has to be safe on its own account, and the preview is now a step ahead of entry.body.html unless you ship a filter that puts it back on the template side. ModifyPreviewEvent has a worked example.
Markdown fields resolve to a wahlberg_Markdown type:
{
entries {
... on article_Entry {
body {
raw
html
text
}
}
}
}
Enter continues a list or a blockquote onto the next line, and ends it on an empty item. The formatting shortcuts are in The toolbar.
The editor grows as the author types, between Minimum Rows and Maximum Rows. Dragging the resize handle takes over from there. Once someone has picked a height by hand, it stops resizing itself.
The Write tab highlights Markdown as you type, and it’s still a plain <textarea> — the colour comes from a layer rendered behind it, with the textarea’s own text made transparent. Native undo, spellcheck, selection and form submission all behave as they otherwise would.
Bold and italic are used only where the font family has real cuts for them. The editor measures on load and falls back to colour alone where a fabricated cut would advance wider and pull the two layers apart. Nothing is lost by that: in Markdown source the ** and _ are on screen anyway.
Retheme with the CSS variables on .wahlberg: --wahlberg-mark, --wahlberg-heading, --wahlberg-strong, --wahlberg-em, --wahlberg-code, --wahlberg-link, --wahlberg-url, --wahlberg-quote. Keep to colour, since setting weight or slope here goes around that measurement. Each defaults to a step on Craft’s own ramp rather than a fixed hex, so a control panel theme that redeclares the palette — its dark mode included — moves the editor with it.
If the two layers ever look out of step, add the wahlberg--debug class to the field to paint the textarea’s own text in red over the layer beneath it.
Mark Wahlberg. Marky Mark. Markdown. I hate myself.