Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
a722b50
Switch from Esprima to Espree for JavaScript linting in CodeMirror.
westonruter Jan 27, 2026
bd2376c
Simplify javascript-lint module definition and ensure proper types
westonruter Jan 28, 2026
9d5d79a
Merge branch 'trunk' of https://github.com/WordPress/wordpress-develo…
westonruter Jan 31, 2026
daa5e2e
Squash sirreal:scripts/allow-script-module-dependency (https://github…
westonruter Jan 31, 2026
fe846ac
CodeMirror: Use native dynamic import for Espree to allow Import Map …
westonruter Jan 31, 2026
34f5e4c
Leverage the module_dependencies arg to add espree to importmap
westonruter Jan 31, 2026
57cd3b0
Fix minification of espree after debugging
westonruter Jan 31, 2026
99a6994
CodeMirror: Unwrap javascript-lint.js IIFE and modernize with const.
westonruter Jan 31, 2026
0f8878d
CodeMirror: Add since 7.0.0 JSDoc tags to javascript-lint.js.
westonruter Jan 31, 2026
89c8db8
Change method for suppressing JSHint warning for console.warn() in ja…
westonruter Jan 31, 2026
0edaefa
Squash sirreal:scripts/allow-script-module-dependency (https://github…
westonruter Feb 4, 2026
7a8d756
Merge branch 'trunk' of https://github.com/WordPress/wordpress-develo…
westonruter Feb 4, 2026
9e3038d
Convert vendor source files to ESM imports.
westonruter Feb 4, 2026
87cd175
Clarify comment for JSHint rules
westonruter Feb 4, 2026
8ce6f62
Merge branch 'trunk' into replace-esprima-with-espree
westonruter Feb 4, 2026
ca2ff24
Merge branch 'trunk' of https://github.com/WordPress/wordpress-develo…
westonruter Feb 7, 2026
2661c91
Simplify Webpack configuration for Espree.
westonruter Feb 7, 2026
64f01f2
Merge branch 'trunk' into replace-esprima-with-espree
westonruter Feb 9, 2026
f2ee120
Add MJS and sourceType: module support
sirreal Feb 10, 2026
5b68233
Use bool directly
sirreal Feb 10, 2026
09a7bab
Add to editable theme files as well.
sirreal Feb 10, 2026
fbf63ef
Merge pull request #5 from sirreal/javascript-support-mjs
westonruter Feb 10, 2026
2dd7d20
Merge branch 'trunk' of https://github.com/WordPress/wordpress-develo…
westonruter Feb 10, 2026
083a8a8
Update tests to account for new module arg
westonruter Feb 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 22 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"@lodder/grunt-postcss": "^3.1.1",
"@playwright/test": "1.56.1",
"@pmmmwh/react-refresh-webpack-plugin": "0.6.1",
"@types/codemirror": "5.60.17",
"@wordpress/e2e-test-utils-playwright": "1.33.2",
"@wordpress/prettier-config": "4.33.1",
"@wordpress/scripts": "30.26.2",
Expand Down Expand Up @@ -79,6 +80,7 @@
"core-js-url-browser": "3.6.4",
"csslint": "1.0.5",
"element-closest": "3.0.2",
"espree": "9.6.1",
"esprima": "4.0.1",
"formdata-polyfill": "4.0.10",
"hoverintent": "2.2.1",
Expand Down
121 changes: 121 additions & 0 deletions src/js/_enqueues/vendor/codemirror/javascript-lint.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/**
* CodeMirror JavaScript linter.
*
* @since 7.0.0
*/

import CodeMirror from 'codemirror';

/**
* CodeMirror Lint Error.
*
* @see https://codemirror.net/5/doc/manual.html#addon_lint
*
* @typedef {Object} CodeMirrorLintError
* @property {string} message - Error message.
* @property {'error'} severity - Severity.
* @property {CodeMirror.Position} from - From position.
* @property {CodeMirror.Position} to - To position.
*/

/**
* JSHint options supported by Espree.
*
* @see https://jshint.com/docs/options/
* @see https://www.npmjs.com/package/espree#options
*
* @typedef {Object} SupportedJSHintOptions
* @property {number} [esversion] - "This option is used to specify the ECMAScript version to which the code must adhere."
* @property {boolean} [es5] - "This option enables syntax first defined in the ECMAScript 5.1 specification. This includes allowing reserved keywords as object properties."
* @property {boolean} [es3] - "This option tells JSHint that your code needs to adhere to ECMAScript 3 specification. Use this option if you need your program to be executable in older browsers—such as Internet Explorer 6/7/8/9—and other legacy JavaScript environments."
* @property {boolean} [module] - "This option informs JSHint that the input code describes an ECMAScript 6 module. All module code is interpreted as strict mode code."
* @property {'implied'} [strict] - "This option requires the code to run in ECMAScript 5's strict mode."
*/

/**
* Validates JavaScript.
*
* @since 7.0.0
*
* @param {string} text - Source.
* @param {SupportedJSHintOptions} options - Linting options.
* @returns {Promise<CodeMirrorLintError[]>}
*/
async function validator( text, options ) {
const errors = /** @type {CodeMirrorLintError[]} */ [];
try {
const espree = await import( /* webpackIgnore: true */ 'espree' );
espree.parse( text, {
...getEspreeOptions( options ),
loc: true,
} );
Comment on lines +47 to +51
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added some logging here (console.log( 'Parsing with: %o', getEspreeOptions( options ) )) to understand the options and I noticed something (note this is with westonruter#5 so adds module config).

The plugin linting seems to be triggered twice, once with what appear to be defaults and again with the expected config. This creates a race, where sometimes on load this is printed:

Parsing with: {ecmaVersion: 'latest', sourceType: 'script', ecmaFeatures: {…}}
Parsing with: {ecmaVersion: 11, sourceType: 'module', ecmaFeatures: {…}}

And the lint is performed as expected. However, sometimes this is the order and the default linting is applied:

Parsing with: {ecmaVersion: 11, sourceType: 'module', ecmaFeatures: {…}}
Parsing with: {ecmaVersion: 'latest', sourceType: 'script', ecmaFeatures: {…}}

This does seem to be happening before this PR, but it seems very consistent. I'm always seeing it run lint with the desired options second.

Copy link
Member Author

@westonruter westonruter Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm seeing that too.

I tried turning off minification and I captured the stack traces for the first and second invocation:

First
codemirror_entry_validator (codemirror.min.js?ver=5.65.20:33014)
startLinting (codemirror.min.js?ver=5.65.20:7501)
(anonymous) (codemirror.min.js?ver=5.65.20:7599)
CodeMirror (codemirror.min.js?ver=5.65.20:19874)
CodeMirror (codemirror.min.js?ver=5.65.20:19818)
fromTextArea (codemirror.min.js?ver=5.65.20:21688)
initialize (code-editor.js?ver=7.0-alpha-61215-src:298)
initCodeEditor (theme-plugin-editor.js?ver=7.0-alpha-61215-src:417)
(anonymous) (theme-plugin-editor.js?ver=7.0-alpha-61215-src:65)
(anonymous) (underscore.js?ver=1.13.7:1091)
setTimeout
(anonymous) (underscore.js?ver=1.13.7:1090)
(anonymous) (underscore.js?ver=1.13.7:76)
executeBound (underscore.js?ver=1.13.7:991)
bound (underscore.js?ver=1.13.7:1011)
init (theme-plugin-editor.js?ver=7.0-alpha-61215-src:64)
(anonymous) (wp-theme-plugin-editor-js-after:2)
mightThrow (jquery.js?ver=3.7.1:3489)
process (jquery.js?ver=3.7.1:3557)
setTimeout
(anonymous) (jquery.js?ver=3.7.1:3602)
fire (jquery.js?ver=3.7.1:3223)
fireWith (jquery.js?ver=3.7.1:3353)
fire (jquery.js?ver=3.7.1:3361)
fire (jquery.js?ver=3.7.1:3223)
fireWith (jquery.js?ver=3.7.1:3353)
ready (jquery.js?ver=3.7.1:3844)
completed (jquery.js?ver=3.7.1:3854)
Second
codemirror_entry_validator (codemirror.min.js?ver=5.65.20:33014)
startLinting (codemirror.min.js?ver=5.65.20:7501)
(anonymous) (codemirror.min.js?ver=5.65.20:7599)
(anonymous) (codemirror.min.js?ver=5.65.20:15878)
setOption (codemirror.min.js?ver=5.65.20:20213)
configureLinting (code-editor.js?ver=7.0-alpha-61215-src:152)
initialize (code-editor.js?ver=7.0-alpha-61215-src:300)
initCodeEditor (theme-plugin-editor.js?ver=7.0-alpha-61215-src:417)
(anonymous) (theme-plugin-editor.js?ver=7.0-alpha-61215-src:65)
(anonymous) (underscore.js?ver=1.13.7:1091)
setTimeout
(anonymous) (underscore.js?ver=1.13.7:1090)
(anonymous) (underscore.js?ver=1.13.7:76)
executeBound (underscore.js?ver=1.13.7:991)
bound (underscore.js?ver=1.13.7:1011)
init (theme-plugin-editor.js?ver=7.0-alpha-61215-src:64)
(anonymous) (wp-theme-plugin-editor-js-after:2)
mightThrow (jquery.js?ver=3.7.1:3489)
process (jquery.js?ver=3.7.1:3557)
setTimeout
(anonymous) (jquery.js?ver=3.7.1:3602)
fire (jquery.js?ver=3.7.1:3223)
fireWith (jquery.js?ver=3.7.1:3353)
fire (jquery.js?ver=3.7.1:3361)
fire (jquery.js?ver=3.7.1:3223)
fireWith (jquery.js?ver=3.7.1:3353)
ready (jquery.js?ver=3.7.1:3844)
completed (jquery.js?ver=3.7.1:3854)

Diff:

@@ -1,10 +1,10 @@
 codemirror_entry_validator (codemirror.min.js?ver=5.65.20:33014)
 startLinting (codemirror.min.js?ver=5.65.20:7501)
 (anonymous) (codemirror.min.js?ver=5.65.20:7599)
-CodeMirror (codemirror.min.js?ver=5.65.20:19874)
-CodeMirror (codemirror.min.js?ver=5.65.20:19818)
-fromTextArea (codemirror.min.js?ver=5.65.20:21688)
-initialize (code-editor.js?ver=7.0-alpha-61215-src:298)
+(anonymous) (codemirror.min.js?ver=5.65.20:15878)
+setOption (codemirror.min.js?ver=5.65.20:20213)
+configureLinting (code-editor.js?ver=7.0-alpha-61215-src:152)
+initialize (code-editor.js?ver=7.0-alpha-61215-src:300)
 initCodeEditor (theme-plugin-editor.js?ver=7.0-alpha-61215-src:417)
 (anonymous) (theme-plugin-editor.js?ver=7.0-alpha-61215-src:65)
 (anonymous) (underscore.js?ver=1.13.7:1091)

The second call is happening when the lint option gets updated here:

editor.setOption( 'lint', getLintOptions() );

The two calls are happening here:

codemirror = wp.CodeMirror.fromTextArea( $textarea[0], instanceSettings.codemirror );
updateErrorNotice = configureLinting( codemirror, instanceSettings );

So it makes sense that it would be called twice, once with the default config and again with the custom config. The initial implementation is clearly not ideal, as this configureLinting() function should be refactored to construct the lint option earlier to be passed in initially in the call to fromTextArea().

I'll include this work in the follow-up PR to improve the typing for code-editor.js.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue is fixed in #10900!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After implementing TypeScript types for the JS files, I then prompted Gemini to resolve the issue based on what I thought needed to be done, as well as providing our conversation as context. Quite pleased 😄

} catch ( error ) {
if (
// This is an `EnhancedSyntaxError` in Espree: <https://github.com/brettz9/espree/blob/3c1120280b24f4a5e4c3125305b072fa0dfca22b/packages/espree/lib/espree.js#L48-L54>.
error instanceof SyntaxError &&
typeof error.lineNumber === 'number' &&
typeof error.column === 'number'
) {
const line = error.lineNumber - 1;
errors.push( {
message: error.message,
severity: 'error',
from: CodeMirror.Pos( line, error.column - 1 ),
to: CodeMirror.Pos( line, error.column ),
} );
} else {
console.warn( '[CodeMirror] Unable to lint JavaScript:', error ); // jshint ignore:line
}
}

return errors;
}

CodeMirror.registerHelper( 'lint', 'javascript', validator );

/**
* Gets the options for Espree from the supported JSHint options.
*
* @since 7.0.0
*
* @param {SupportedJSHintOptions} options - Linting options for JSHint.
* @return {{
* ecmaVersion?: number|'latest',
* ecmaFeatures?: {
* impliedStrict?: true
* }
* }}
*/
function getEspreeOptions( options ) {
const ecmaFeatures = {};
if ( options.strict === 'implied' ) {
ecmaFeatures.impliedStrict = true;
}

return {
ecmaVersion: getEcmaVersion( options ),
sourceType: options.module ? 'module' : 'script',
ecmaFeatures,
};
}

/**
* Gets the ECMAScript version.
*
* @since 7.0.0
*
* @param {SupportedJSHintOptions} options - Options.
* @return {number|'latest'} ECMAScript version.
*/
function getEcmaVersion( options ) {
if ( typeof options.esversion === 'number' ) {
return options.esversion;
}
if ( options.es5 ) {
return 5;
}
if ( options.es3 ) {
return 3;
}
return 'latest';
}
2 changes: 2 additions & 0 deletions src/wp-admin/includes/file.php
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ function wp_get_plugin_file_editable_extensions( $plugin ) {
'inc',
'include',
'js',
'mjs',
'json',
'jsx',
'less',
Expand Down Expand Up @@ -261,6 +262,7 @@ function wp_get_theme_file_editable_extensions( $theme ) {
'inc',
'include',
'js',
'mjs',
'json',
'jsx',
'less',
Expand Down
60 changes: 34 additions & 26 deletions src/wp-includes/general-template.php
Original file line number Diff line number Diff line change
Expand Up @@ -4069,7 +4069,6 @@ function wp_enqueue_code_editor( $args ) {
case 'text/x-php':
wp_enqueue_script( 'htmlhint' );
wp_enqueue_script( 'csslint' );
wp_enqueue_script( 'jshint' );
if ( ! current_user_can( 'unfiltered_html' ) ) {
wp_enqueue_script( 'htmlhint-kses' );
}
Expand All @@ -4081,7 +4080,6 @@ function wp_enqueue_code_editor( $args ) {
case 'application/ld+json':
case 'text/typescript':
case 'application/typescript':
wp_enqueue_script( 'jshint' );
wp_enqueue_script( 'jsonlint' );
break;
}
Expand Down Expand Up @@ -4153,30 +4151,39 @@ function wp_get_code_editor_settings( $args ) {
'outline-none' => true,
),
'jshint' => array(
// The following are copied from <https://github.com/WordPress/wordpress-develop/blob/4.8.1/.jshintrc>.
'boss' => true,
'curly' => true,
'eqeqeq' => true,
'eqnull' => true,
'es3' => true,
'expr' => true,
'immed' => true,
'noarg' => true,
'nonbsp' => true,
'onevar' => true,
'quotmark' => 'single',
'trailing' => true,
'undef' => true,
'unused' => true,

'browser' => true,

'globals' => array(
'_' => false,
'Backbone' => false,
'jQuery' => false,
'JSON' => false,
'wp' => false,
'esversion' => 11,
'module' => str_ends_with( $args['file'] ?? '', '.mjs' ),

// The following JSHint *linting rule* options are copied from
// <https://github.com/WordPress/wordpress-develop/blob/6.9.0/.jshintrc>.
// Parsing-related options such as `esversion` (and, in other contexts, `es5`, `es3`, `module`, `strict`)
// are honored by the Espree-based integration, but these linting-rule options are not interpreted by Espree
// and are kept only for compatibility/documentation with the original JSHint configuration.
'boss' => true,
'curly' => true,
'eqeqeq' => true,
'eqnull' => true,
'expr' => true,
'immed' => true,
'noarg' => true,
'nonbsp' => true,
'quotmark' => 'single',
'undef' => true,
'unused' => true,
'browser' => true,
'globals' => array(
'_' => false,
'Backbone' => false,
'jQuery' => false,
'JSON' => false,
'wp' => false,
'export' => false,
'module' => false,
'require' => false,
'WorkerGlobalScope' => false,
'self' => false,
'OffscreenCanvas' => false,
'Promise' => false,
),
),
'htmlhint' => array(
Expand Down Expand Up @@ -4233,6 +4240,7 @@ function wp_get_code_editor_settings( $args ) {
$type = 'message/http';
break;
case 'js':
case 'mjs':
$type = 'text/javascript';
break;
case 'json':
Expand Down
5 changes: 3 additions & 2 deletions src/wp-includes/script-loader.php
Original file line number Diff line number Diff line change
Expand Up @@ -1196,9 +1196,10 @@ function wp_default_scripts( $scripts ) {
);

$scripts->add( 'wp-codemirror', '/wp-includes/js/codemirror/codemirror.min.js', array(), '5.65.20' );
did_action( 'init' ) && $scripts->add_data( 'wp-codemirror', 'module_dependencies', array( 'espree' ) );
$scripts->add( 'csslint', '/wp-includes/js/codemirror/csslint.js', array(), '1.0.5' );
$scripts->add( 'esprima', '/wp-includes/js/codemirror/esprima.js', array(), '4.0.1' );
$scripts->add( 'jshint', '/wp-includes/js/codemirror/fakejshint.js', array( 'esprima' ), '2.9.5' );
$scripts->add( 'esprima', '/wp-includes/js/codemirror/esprima.js', array(), '4.0.1' ); // Deprecated. Use 'espree' script module.
$scripts->add( 'jshint', '/wp-includes/js/codemirror/fakejshint.js', array( 'esprima' ), '2.9.5' ); // Deprecated.
$scripts->add( 'jsonlint', '/wp-includes/js/codemirror/jsonlint.js', array(), '1.6.3' );
$scripts->add( 'htmlhint', '/wp-includes/js/codemirror/htmlhint.js', array(), '1.8.0' );
$scripts->add( 'htmlhint-kses', '/wp-includes/js/codemirror/htmlhint-kses.js', array( 'htmlhint' ) );
Expand Down
7 changes: 7 additions & 0 deletions src/wp-includes/script-modules.php
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,13 @@ function wp_default_script_modules() {
$module_deps = $script_module_data['module_dependencies'] ?? array();
wp_register_script_module( $script_module_id, $path, $module_deps, $script_module_data['version'], $args );
}

wp_register_script_module(
'espree',
includes_url( 'js/codemirror/espree.min.js' ),
array(),
'9.6.1'
);
}

/**
Expand Down
Loading
Loading