bensomething/craft-wahlberg

A Markdown field with a GitHub-style editor.

Maintainers

Package info

github.com/bensomething/craft-wahlberg

Type:craft-plugin

pkg:composer/bensomething/craft-wahlberg

Transparency log

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

1.0.0-beta.5 2026-07-31 11:45 UTC

This package is auto-updated.

Last update: 2026-07-31 11:46:23 UTC


README

A Markdown field with a GitHub-style editor: a Write tab, a Preview tab, and a formatting toolbar.

Note

Wahlberg is in beta. The templating surface, |marky filter, wahlberg_Markdown GraphQL type, Editor::inputHtml() options, both events, and the config/wahlberg.php snippet format are settled. The field’s own settings may still move.

  • Raw Markdown in, raw Markdown out
  • Server-side preview, parsed with the same parser as Craft’s |md filter, so it can’t drift from the front end
  • HTML Purifier on the parsed output by default, so inline <script> can’t ride in on an author’s Markdown
  • Craft reference tags resolved on output and left alone inside code, and written by the entry and asset pickers, so links survive a slug change or a replaced file
  • Markdown syntax highlighting in the Write tab without giving up the plain textarea, and a band behind the line being written
  • Formatting toolbar of headings, bold, italic, strikethrough, quote, code, link, element pickers and lists, folding into a menu when the field is too narrow. Or a floating one, in a panel over the selection
  • / at the caret opens an insert menu of the element pickers and your snippets, filtered as you type
  • Snippets you define in config, with tab stops and defaults
  • ⌘B / ⌘I / ⌘K, ⌘⇧E and ⌘⇧U for the pickers, ⌘⇧K for snippets, ⌘⇧P to preview, ⌘⇧F for the floating toolbar
  • Buttons toggle, Enter carries on a list, and native browser undo survives all of it
  • The editor grows to fit what’s typed, sticks its header on a tall field, and keeps your place across the tabs
  • No editor library bundled: a textarea, some vanilla JS, and Craft’s own icons
  • Per-field flavour, toolbar, line length, sizing, limits and counts
  • GraphQL support
  • Reusable outside the field type, from Twig or PHP

Requirements

  • Craft CMS 5
  • PHP 8.2+

Installation

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.

The Field

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.
New Paragraph on Enter Off Enter leaves a blank line, so it starts a paragraph rather than a line that runs back into the one above. ⇧Enter still gives the single newline, and Enter still carries on a list or a quote. Hidden, and ignored, when Inline Only is on: a field with no paragraphs in its output has no use for a key that makes them.

Editor

Setting Default
Text Size 14px The Markdown source in the editor, 11–20px. Editing comfort only, with no bearing on the front end.
Line Length Full width How far the text runs before it wraps. Comfortable holds it to about 80 characters and leaves the spare room at the end of the line. Comfortable (centred) splits that room between both edges. --wahlberg-measure sets the width. Editing comfort only.
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.
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.
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.

Toolbar

Setting Default
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.
Floating Toolbar Off Puts the buttons in a panel over the selection rather than in a strip above the editor. Nothing formatting-related is on screen until an author selects something or hits ⌘⇧F. See Floating.
Toolbar Buttons all but Heading 1, Heading 3–6 and the guide Which buttons the toolbar offers. See The toolbar.
Show Stats Off Character, word and line counts under the editor, with the field limit alongside when there is one.

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.

The toolbar

Every button is optional, and which ones a field offers is up to Toolbar Buttons:

Button Shortcut What it writes
Heading 1Heading 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 ⌘⇧E opens Craft’s element selector, see below
Asset ⌘⇧U 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, also on /. 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 . Asset is ⌘⇧U rather than the ⌘⇧A you’d expect, because macOS browsers keep that one for themselves and it never reaches the page.

The buttons fold into a menu when the toolbar 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.

Floating

With Floating Toolbar on, the buttons leave the header for a panel over the text, pointed at whatever’s selected. It sits above or below depending on which side has room.

This isn’t a look, and it costs something. The field opens with no formatting buttons at all, the Markdown guide among them, and they turn up only once an author selects something. Good for authors who write Markdown all day, bad for authors who don’t, which is what the setting really chooses between.

Half of what the toolbar offers goes in at the caret rather than around a selection: a heading, a list, a quote, an entry, a snippet. So the panel doesn’t only answer to selections.

A selection the panel appears, pointed at it
⌘⇧F the panel at the caret, with nothing selected, until Esc
Esc puts it away until something else is selected

It stays put while a menu it opened is up, follows the text as the field scrolls or reflows, and goes away when the field loses focus, when the Preview tab comes up, or when the text it was pointing at scrolls out of a field that’s hit its maximum height.

Headings

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.

Entry and Asset

Both open Craft’s element selector. Entry writes a link. Asset writes an image as ![alt](…) 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})

![Ada Lovelace]({asset:41: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. An entry with no URL of its own writes an empty one.

Snippets

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 without gets a neutral stand-in, so the labels line up either way.

Markers, all optional:

  • $1 to $9 are stops. Insert a snippet with more than one and the caret lands on the first. Tab moves to the next, ⇧Tab back.
  • ${1:like this} is a stop with a default. The text goes in, and landing on the stop selects it, so it reads as a prompt and types over as a placeholder. Bare ${1} is the same as $1.
  • $0 is where the caret ends up: the last stop, after the numbered ones. Without any marker the caret lands at the end.
  • $SELECTION is 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.
'table' => "| \${1:Column} | \${2:Column} |\n| --- | --- |\n| \$3 | \$0 |\n",
'link' => "[\${1:\$SELECTION}](\${0:https://})",

Defaults earn their characters on anything with more than a couple of stops: a bare stop is somewhere to go, a filled one says what goes there. A default is ordinary text, so $SELECTION inside one is still substituted and the stop comes out covering whatever it stood in for. There’s no nesting: a default runs to the first }.

Tab belongs to a run only while one is going, since it’s also how you leave a field. Esc ends a run early, and it ends on its own at the last stop or when the caret leaves the text the snippet put in. A body carrying only $0 is a caret position rather than a run, and behaves as it always did.

Mind the quoting. Inside a double-quoted PHP string, $0 and ${1:…} read as variables, so escape the $ as \$0 and \${1:…}. 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, so the person writing one should be the person who can write those too, and the definition should travel with the code. 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.

Opening the menu

Typing / opens it at the caret, and what you type after that narrows the list. /cal gets you to a callout without reaching for the arrow keys. ⌘⇧K opens it at the caret too, and clicking the toolbar button opens it under the button, since that's where the eye already is.

Neither needs the button: both work 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.

The / menu also offers Entry and Asset, above the snippets and divided off from them. They're the two commands that put something in at the caret rather than reshaping what's around it, which is the only kind a menu opened by typing can offer. There's nothing selected to make bold.

They don't follow Toolbar Buttons. That setting says what the toolbar shows, not what the field can do, and ⌘⇧E and ⌘⇧U work with every button unticked. So does the snippet half of this menu. On a field with no snippets at all, the menu is there for the two commands alone.

⌘⇧K and the button stay snippets-only. Both have meant snippets since before there was anything else in the list.

What counts as a /. Only one at the start of a line or after a space, and never inside a fenced code block. Markdown source is full of the other kind, in URLs, paths, closing tags and dates. Past that the list narrows as you type and closes the moment nothing matches, so a slash that wasn't meant as a command costs a flicker rather than a dismissal.

Mind what your fields render. With Purify HTML on, the default, raw HTML in a snippet is sanitised on the way out by a purifier that 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.

From a plugin

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 and pass definitions to Editor::inputHtml() instead. See Using the editor in your own plugin.

Templating

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) }}

The |marky filter

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 %}

Reference tags

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}.

![Diagram]({asset:456:url})

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 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. Tags are resolved after parsing, when the parser has already decided what counts as code, so nothing here second-guesses Markdown’s fence rules.
  • 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) }}.

Raw HTML and purification

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 as it stores, because what’s stored is HTML. Here the stored value is Markdown source, and purifying that would mangle it: autolinks like <https://example.com> and < inside code fences are not markup. So it happens each time .html is rendered, and the raw Markdown is never touched.
  • |md bypasses it. {{ entry.body.html }} is purified. {{ entry.body|md }} runs Craft's filter over the raw value and isn’t. That’s deliberate, since .raw has to stay pristine, but it means the protection lives on one particular path. |marky is on that path, |md isn’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.

Encoding instead

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 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, so **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 &amp;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.

Using the editor in your own plugin

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, floating, 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, parsing with whatever flavour you pass and always purifying, 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.

For somewhere a value is shown rather than edited, Editor::staticHtml([ ... ]) renders the Markdown as it was written on the same surface, in the same type, with no textarea and nothing to run:

echo Editor::staticHtml([
    'value' => $model->notes,
]);

Options: value, fontSize and lineLength.

Reach for it anywhere the JavaScript won’t be there. Disabling the editor’s inputs isn’t enough on its own, because the textarea paints its own text transparent for the highlighted layer to show through, and that layer is filled by the JS. This is what a Markdown field renders in a revision, and in any other read-only form Craft builds.

What’s public API here is the four 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.

Hooking the Preview tab

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 is 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.

GraphQL

Markdown fields resolve to a wahlberg_Markdown type:

{
  entries {
    ... on article_Entry {
      body {
        raw
        html
        text
      }
    }
  }
}

Editing

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.

What Enter does

With New Paragraph on Enter on, Enter leaves a blank line behind it and ⇧Enter gives the single newline. That's the division every rich text editor makes.

It's off by default, because a field whose authors write Markdown doesn't need it. Turn it on for one whose authors don't. A single newline is the one piece of Markdown that does nothing you can see: a <br> with Preserve Line Breaks on, and with it off a space, so the two lines you just separated come back as one. Neither is a paragraph, and "press it twice" is not something anyone arrives already knowing.

Enter keeps its other jobs either way, carrying on a list or a quote and ending one on an empty item. A blank line already under the caret isn't doubled, so holding Enter down leaves a gap rather than a pile of them.

It sits with Preserve Line Breaks and Inline Only because the three are one question: what a line break means in this field. It disappears when Inline Only is on, and is ignored there, since a field rendering without a <p> has no use for a key that starts one.

⌘⇧P swaps between Write and Preview, from anywhere in the field, and puts the caret back where it was on the way in. It does nothing on a field with Show Preview Tab off, or while there’s nothing written to preview.

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 header

Once a field fills more than half the window, its header sticks. The tabs and the toolbar hold still and the text scrolls under them, so the buttons are still there when the writing has run past where they were. That’s measured on the Write tab whichever tab is up, because rendered Markdown is shorter than its source, and a header that came and went as you switched would be worse than one that never held still.

Below that they scroll away with everything else. A short field is gone almost as soon as its header is, so sticking it would only slide the strip over the last few rows on the way past.

It stops below Craft’s own page header, which pins itself to the top of the window once the page scrolls. Craft has no token for that height and it isn’t a fixed number, so the editor measures it. In a slideout, a modal, or anywhere else scrolling in a box of its own it’s zero, since the field’s header already stops below that thing’s chrome.

There’s no setting for it and no threshold to tune. If your control panel has put something else along the top, --wahlberg-sticky-top on .wahlberg overrules the measurement.

Keeping your place

Switching between Write and Preview halfway down a long field puts you back where you were, rather than at the top.

What’s matched is structure, not distance. The two panes hold the same content at wildly different lengths, since a link is a URL’s worth of source and a word of rendered text, and a reference tag is worse. A percentage or a pixel offset would land somewhere arbitrary. So the source is split into the blocks the parser turns into elements, and the block at the top of one pane is the element put at the top of the other.

Blocks and elements come out one for one nearly always. Where they don’t, as when a list with blank lines between its items is several blocks of source and a single <ul>, the position is scaled rather than trusted, landing in the right region instead of on the wrong paragraph. At the top of a field, switching does nothing at all.

The current line

The line being written carries a band behind it, the way a code editor does. A long line wraps over several rows and the band covers all of them, since what it marks is the line the author is on rather than the row the caret is in. In Markdown that block is usually the paragraph.

It’s up only while the field has focus and nothing is selected. A selection already says where the author is, and a page of fields each wearing a band says nothing at all.

In light mode it’s --gray-050, the same step on Craft’s ramp the field’s header sits on. In dark it’s a 3% lift off whatever the surface is. Nothing else about the field’s colours changes.

There’s no setting for it. Retheme or switch it off with the CSS variables on .wahlberg:

Variable Default
--wahlberg-active-line --gray-050, or a 3% lift in dark mode transparent to do without
--wahlberg-active-line-pad 1px how far the band stands proud of the row, top and bottom
--wahlberg-line-height 1.6 the row itself, which the caret and the selection are drawn to as well
--wahlberg-measure 48em how wide the text runs under Line Length, about 80 characters

Both text layers take the last two from the same place on purpose: wrapping in different places, or sitting on different line heights, is the one mismatch the editor can’t measure its way out of. That’s why --wahlberg-measure is in em and not the ch a monospace column asks for. ch is the width of a 0 in the face the browser resolved, and the two layers don’t always resolve the same one.

Reach for --wahlberg-line-height if the caret looks too tall for the text. A browser draws it to the full row, so the only way to shorten it is to tighten the row.

Syntax highlighting

The Write tab highlights Markdown as you type and is still a plain <textarea>. The colour comes from a layer rendered behind it, with the textarea’s own text made transparent, so 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, since 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, 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.

Why “Wahlberg”?

Mark Wahlberg. Marky Mark. Markdown. I hate myself.