wp-admin-codemirror

Embed WordPress's bundled CodeMirror editor in admin pages via `wp_enqueue_code_editor()` and `wp.codeEditor.initialize()`. Covers MIME / file mode selection for CSS, JS, JSON, HTML, PHP, SQL, Markdow

SKILL.md

GitHub

WordPress Admin CodeMirror (wp.codeEditor)

WP bundles CodeMirror 5 plus its linter add-ons. Plugins almost never use it because the entry point is one underused function (wp_enqueue_code_editor) and the JS init is split between two files. This skill is the bootstrap recipe.

When to use this skill

Trigger when ANY of the following is true:

  • The user is adding a <textarea> to admin for CSS / JSON / JS / HTML / PHP / SQL / regex / Markdown / YAML content and wants syntax highlighting (NOT the block editor).
  • Code references wp_enqueue_code_editor, wp_get_code_editor_settings, wp.codeEditor, wp.CodeMirror, wp_code_editor_settings, code-editor script handle.
  • The user is building a "custom CSS field", "snippet editor", "JSON config editor", "webhook payload field", "regex builder", "shortcode preview", "rules engine".
  • The user mentions CodeMirror, Monaco, Ace, Prism specifically in a WP-admin context — and the answer is "use the bundled CodeMirror".

The five-step bootstrap

add_action( 'admin_enqueue_scripts', static function ( string $hook_suffix ): void {
    // 1. Only on your screen.
    if ( 'settings_page_myplugin' !== $hook_suffix ) {
        return;
    }

    // 2. Ask WP to enqueue CodeMirror and bundle a settings snapshot for this MIME type.
    //    Use 'type' (MIME) or 'file' (extension sniff).
    $settings = wp_enqueue_code_editor( array( 'type' => 'text/css' ) );

    // 3. CRITICAL: $settings is false when the user has disabled syntax highlighting
    //    in their profile. Stop here; the textarea stays as a plain textarea.
    if ( false === $settings ) {
        return;
    }

    // 4. Enqueue YOUR admin script that calls wp.codeEditor.initialize().
    wp_enqueue_script(
        'myplugin-css-editor',
        plugins_url( 'assets/css-editor.js', MYPLUGIN_FILE ),
        array( 'code-editor', 'wp-i18n' ),
        MYPLUGIN_VERSION,
        array( 'in_footer' => true )
    );

    // 5. Pass the textarea ID and per-instance settings to your script.
    wp_add_inline_script(
        'myplugin-css-editor',
        'window.MyPluginCssEditor = ' . wp_json_encode( array(
            'id'       => 'myplugin_custom_css',
            'settings' => $settings,
        ) ) . ';',
        'before'
    );
} );
// assets/css-editor.js
jQuery( function () {
    if ( ! window.wp || ! wp.codeEditor || ! window.MyPluginCssEditor ) {
        return;
    }
    wp.codeEditor.initialize(
        MyPluginCssEditor.id,
        MyPluginCssEditor.settings
    );
} );

When the first argument is a string, pass the bare textarea id with no #. If you want to pass a CSS selector, pass a jQuery object instead: wp.codeEditor.initialize( jQuery( '#myplugin_custom_css' ), settings ).

// In your settings view, the underlying field stays a plain <textarea>.
// Submit-time the form posts the textarea value as if CodeMirror weren't there.
?>
<textarea id="myplugin_custom_css" name="myplugin_options[custom_css]" rows="10" cols="60"><?php
    echo esc_textarea( $options['custom_css'] ?? '' );
?></textarea>

That's the whole flow for a native form submission. CodeMirror's EditorFromTextArea saves its value back into the original <textarea> when that form submits.

Reading / writing programmatically

wp.codeEditor.initialize() returns an object with .codemirror (the underlying CodeMirror 5 instance with full API):

const editor = wp.codeEditor.initialize( 'myplugin_custom_css' );

editor.codemirror.getValue();                          // current text
editor.codemirror.setValue( '/* new content */' );     // replace text
editor.codemirror.on( 'change', () => { /* ... */ } ); // change events
editor.codemirror.focus();
editor.codemirror.refresh();                           // call after un-hiding (tabs, accordion)
editor.updateErrorNotice();                            // force-refresh the linter notice

The .refresh() call is non-obvious and important — if your editor lives inside a hidden tab, accordion, or modal that's shown later, the editor renders at width=0 until you refresh() after it becomes visible.

Picking the right type / file

wp_enqueue_code_editor() accepts either type (a MIME string) or file (a filename — extension is sniffed). The full per-MIME mode mapping lives in wp_get_code_editor_settings() (wp-includes/general-template.php:4128). Common values:

Use case Recommended call
CSS array( 'type' => 'text/css' )
JavaScript array( 'type' => 'text/javascript' )
JSON array( 'type' => 'application/json' )
HTML array( 'type' => 'text/html' )
PHP array( 'type' => 'application/x-httpd-php' )
SQL array( 'type' => 'text/x-sql' )
Markdown array( 'type' => 'text/x-markdown' )
YAML array( 'type' => 'text/x-yaml' )
Filename sniff array( 'file' => 'config.json' )

Linter handles WP auto-enqueues

Based on the MIME and the final codemirror.lint setting, wp_enqueue_code_editor() registers the relevant linter (wp-includes/general-template.php:4053-4090):

MIME / setting Linter handles enqueued
CSS / SCSS / LESS with WP defaults None — these modes default to lint: false
CSS / SCSS / LESS after you explicitly set codemirror.lint: true csslint
HTML with default lint: true htmlhint, csslint, plus htmlhint-kses if the user lacks unfiltered_html
PHP with WP defaults None — PHP mode defaults to lint: false
PHP after you explicitly set codemirror.lint: true htmlhint, csslint, plus htmlhint-kses if the user lacks unfiltered_html
JavaScript / JSON with default lint: true jsonlint; JS lint settings also include the Espree module URL in current WP

You don't enqueue these — listing them above is so you can predict what shows up in DevTools and so you know what's missing if you replace the default settings.

Disabling the linter for a specific instance

Pass lint: false in the codemirror settings to suppress the gutter and the "X errors" notice:

wp.codeEditor.initialize( 'read-only-preview', {
    codemirror: { readOnly: true, lint: false },
} );

Customizing defaults globally — wp_code_editor_settings filter

When you want to change CodeMirror defaults for everyone (e.g. force 2-space indent, add a custom mode, disable a CSSLint rule for the whole admin), filter wp_code_editor_settings:

add_filter( 'wp_code_editor_settings', static function ( array $settings, array $args ): array {
    if ( ( $args['type'] ?? '' ) !== 'text/css' ) {
        return $settings;
    }

    // Override CodeMirror options.
    $settings['codemirror']['indentUnit']     = 2;
    $settings['codemirror']['indentWithTabs'] = false;

    // Disable a specific CSSLint rule.
    $settings['csslint']['box-model'] = false;

    return $settings;
}, 10, 2 );

Defaults you'll usually want to keep (set in wp_get_code_editor_settings() itself, line 4128): lineNumbers: true, lineWrapping: true, styleActiveLine: true, continueComments: true, plus shortcuts Ctrl-Space autocomplete, Ctrl-/ toggleComment, Ctrl-F findPersistent.

The two callbacks worth knowing

wp.codeEditor.initialize( 'my-editor', {
    onChangeLintingErrors: function ( errorAnnotations, allAnnotations ) {
        // Disable the form's Save button while there are lint errors.
        const $save = jQuery( '#submit' );
        $save.prop( 'disabled', errorAnnotations.length > 0 );
    },
    onUpdateErrorNotice: function ( errorAnnotations, codemirror ) {
        // Custom UI for the error notice. WP shows its own by default.
    },
} );

onChangeLintingErrors is the right hook for "block save until valid" UX. Don't poll .codemirror.state from a setInterval — that's the AI-default but wrong pattern.

Critical rules

  • Always guard against wp_enqueue_code_editor() returning false. If the user has disabled syntax highlighting in their profile (Users → Your Profile → Syntax Highlighting), the call returns false and does NOT enqueue. Your follow-up wp_enqueue_script( 'my-code-editor' ) would still load and throw wp.codeEditor is not defined. Defensive guard:

    $settings = wp_enqueue_code_editor( array( 'type' => 'text/css' ) );
    if ( false === $settings ) {
        return; // Leave the plain <textarea> alone.
    }
    

    And in JS:

    if ( ! window.wp || ! wp.codeEditor ) {
        return;
    }
    
  • Call initialize() inside DOMContentLoaded. The function itself logs console.warn('wp.codeEditor.initialize() ran too early...') when called pre-DOMContentLoaded (wp-admin/js/code-editor.js:417). Wrap in jQuery( function () { ... } ) or document.addEventListener( 'DOMContentLoaded', ... ).

  • Don't wp_enqueue_code_editor() globally. It runs on every admin page-load and adds ~6 scripts + 1 stylesheet plus linters. Always gate on $hook_suffix.

  • The <textarea> must exist before initialize() runs. CodeMirror replaces the textarea in the DOM — late-added textareas (e.g. in a repeater) need their own initialize() after insertion.

  • Don't re-initialize an already-initialized textarea. Track instances; if you must re-init, call instance.codemirror.toTextArea() first to detach.

  • Native form submit is synchronized; arbitrary serialization is not. EditorFromTextArea updates the original <textarea> for the form's native submit event. Before programmatically reading or serializing that textarea, call instance.codemirror.save() or use getValue() directly. It does not write the textarea on every editor change.

Common mistakes

// WRONG — assumes wp.codeEditor exists; throws when user disabled highlighting in profile
wp.codeEditor.initialize( 'snippet' );

// RIGHT — guard
if ( window.wp && wp.codeEditor ) {
    wp.codeEditor.initialize( 'snippet' );
}
// WRONG — runs on every admin page
add_action( 'admin_enqueue_scripts', 'my_plugin_load_code_editor' );
function my_plugin_load_code_editor(): void {
    wp_enqueue_code_editor( array( 'type' => 'text/css' ) );
}

// RIGHT — gate on the screen
add_action( 'admin_enqueue_scripts', static function ( string $hook_suffix ): void {
    if ( 'settings_page_myplugin' !== $hook_suffix ) {
        return;
    }
    wp_enqueue_code_editor( array( 'type' => 'text/css' ) );
    // ...
} );
// WRONG — editor inside hidden tab renders at width=0; user sees nothing
$tab.show();

// RIGHT — refresh after show
$tab.show();
instance.codemirror.refresh();
// WRONG — using PHPCS / phpcbf style ruleset; CodeMirror uses CSSLint / JSHint / HTMLHint
$settings['codemirror']['phpcs'] = array( 'WordPress' );

// RIGHT — those linters don't exist client-side. Disable the lint if you only want highlighting.
$settings['codemirror']['lint'] = false;

Cross-references

  • See wp-plugin-assets-loading for the canonical $hook_suffix gate pattern.
  • See wp-admin-form-controls for the sibling form controls (color picker, date picker, pointer) that often sit on the same settings page as a code field.
  • See wp-admin-settings-api when the code field is part of a register_setting() options page; the sanitize_callback runs on the textarea value, which CodeMirror has already synced.

What this skill does NOT cover

  • The Customizer / block editor code-editing surfaces. Those use the same wp.codeEditor but the host integration differs.
  • Replacing CodeMirror with Monaco or Ace. Out of scope — use the bundled one.
  • Custom CodeMirror modes not bundled in core. WP ships CSS, JS, JSON, HTML mixed, PHP (clike), XML, Markdown, YAML, SQL, Stylus, SCSS, LESS, JSX. Anything else, you bundle yourself.
  • Server-side validation of the submitted code. That's a sanitize_callback / wp_kses topic, not a CodeMirror topic.

References