CM6: Switch to using Rollup instead of Webpack; make RL-compatible

See https://w.wiki/9Twh for example usage with ResourceLoader.

Webpack is retired in favor of Rollup, which allows us to convert the
ECMAScript Modules into CommonJS modules for use by ResourceLoader.
We now have a file in dist/ for each RL module that we want to offer,
including the 'lib' module which includes the CM library itself.

Because Rollup has no knowledge of the ResourceLoader module registry,
the generated output requires other modules via relative path, when it
needs to be the RL module name. To get around this, we do a crude
find/replace after the files are generated. Hacky, but necessary to make
CodeMirror usable by gadgets and scripts that don't also want
WikiEditor.

Add new RL modules 'ext.CodeMirror.v6.lib' (vendor code) and
'ext.CodeMirror.v6' (the main CodeMirror class, sans WikiEditor).

Clean up extension.json, listing the v6 modules beneath the old ones.

Bug: T214989
Change-Id: Ide716247e545cf2bdd977bea645729564ebbe6e2
This commit is contained in:
MusikAnimal 2024-03-13 00:24:53 -04:00
parent 00f947e97f
commit ca02360228
16 changed files with 1153 additions and 2090 deletions

View file

@ -31,7 +31,6 @@ _NOTE: Consider using [Fresh](https://gerrit.wikimedia.org/g/fresh/) to run thes
* `npm run test:i18n` for linting of i18n messages with banana-checker.
* `npm run test:unit` for the new Jest unit tests.
* `npm run selenium-test` for the Selenium tests.
* `npm run test:bundlesize` to test if the gzip'd entrypoint is of acceptable size.
Older QUnit tests are in `resources/mode/mediawiki/tests/qunit/`. These will
eventually be moved over to `tests/qunit` and rewritten for CodeMirror 6.

View file

@ -166,32 +166,24 @@
"codemirror-toggle-label"
]
},
"ext.CodeMirror.v6.WikiEditor": {
"ext.CodeMirror.v6": {
"dependencies": [
"ext.wikiEditor",
"web2017-polyfills",
"mediawiki.api",
"mediawiki.user",
"user.options",
"ext.CodeMirror.v6.messages"
"ext.CodeMirror.v6.lib"
],
"packageFiles": [
"dist/main.js",
"dist/codemirror.js",
{
"name": "ext.CodeMirror.data.js",
"callback": "MediaWiki\\Extension\\CodeMirror\\DataScript::makeScript"
}
],
"styles": [
"ext.CodeMirror.v6.less",
"mode/mediawiki/mediawiki.less",
"mode/mediawiki/colorblind-colors.less"
"ext.CodeMirror.v6.less"
],
"messages": [
"codemirror-toggle-label"
]
},
"ext.CodeMirror.v6.messages": {
"messages": [
"codemirror-find",
"codemirror-next",
@ -231,6 +223,38 @@
"codemirror-unfold",
"codemirror-folded-code"
]
},
"ext.CodeMirror.v6.lib": {
"packageFiles": [
"dist/vendor.js"
],
"dependencies": [
"web2017-polyfills"
]
},
"ext.CodeMirror.v6.mode.mediawiki": {
"packageFiles": "dist/codemirror.mode.mediawiki.js",
"styles": [
"mode/mediawiki/mediawiki.less",
"mode/mediawiki/colorblind-colors.less"
],
"dependencies": [
"ext.CodeMirror.v6",
"ext.CodeMirror.v6.lib"
]
},
"ext.CodeMirror.v6.WikiEditor": {
"dependencies": [
"ext.wikiEditor",
"ext.CodeMirror.v6.lib",
"ext.CodeMirror.v6.mode.mediawiki"
],
"packageFiles": [
"dist/codemirror.wikieditor.mediawiki.js"
],
"messages": [
"codemirror-toggle-label"
]
}
},
"ResourceFileModulePaths": {

2955
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -2,15 +2,14 @@
"name": "codemirror",
"private": true,
"scripts": {
"start": "webpack -w --mode=development",
"build": "webpack --mode=production",
"test": "npm run test:lint && npm run test:unit && npm run check-built-assets && bundlesize",
"start": "rollup -c --watch",
"build": "rollup -c",
"test": "npm run test:lint && npm run test:unit && npm run check-built-assets",
"test:lint": "npm run test:lint:styles && npm run test:lint:js && npm run test:lint:i18n",
"test:lint:js": "eslint --cache .",
"test:lint:styles": "stylelint \"resources/**/*.less\"",
"test:lint:i18n": "banana-checker i18n/",
"test:unit": "jest",
"test:bundlesize": "bundlesize",
"check-built-assets": "{ git status src/ | grep \"nothing to commit, working tree clean\"; } && { echo 'CHECKING BUILD SOURCES ARE COMMITTED' && npm run build && git status resources/dist/ | grep \"nothing to commit, working tree clean\" || { npm run node-debug; false; }; }",
"node-debug": "node -v && npm -v && echo 'ERROR: Please ensure that production assets have been built with `npm run build` and commited, and that you are using the correct version of Node/NPM.'",
"selenium-test": "wdio tests/selenium/wdio.conf.js"
@ -19,40 +18,32 @@
"node": "18.17.0"
},
"devDependencies": {
"@babel/core": "7.22.20",
"@babel/plugin-transform-private-methods": "^7.22.5",
"@babel/plugin-transform-runtime": "7.22.15",
"@babel/preset-env": "7.3.0",
"@babel/plugin-transform-private-methods": "7.23.3",
"@babel/preset-env": "7.24.0",
"@codemirror/commands": "6.2.5",
"@codemirror/language": "6.9.3",
"@codemirror/search": "6.5.4",
"@codemirror/state": "6.2.1",
"@codemirror/view": "6.22.2",
"@lezer/highlight": "1.2.0",
"@rollup/plugin-babel": "6.0.4",
"@rollup/plugin-node-resolve": "15.2.3",
"@rollup/plugin-terser": "0.4.4",
"@wdio/cli": "7.30.1",
"@wdio/junit-reporter": "7.29.1",
"@wdio/local-runner": "7.30.1",
"@wdio/mocha-framework": "7.26.0",
"@wdio/spec-reporter": "7.29.1",
"@wikimedia/mw-node-qunit": "7.2.0",
"babel-loader": "9.1.3",
"bundlesize": "0.18.2",
"clean-webpack-plugin": "3.0.0",
"dotenv": "8.2.0",
"eslint-config-wikimedia": "0.26.0",
"grunt-banana-checker": "0.11.1",
"jest": "29.7.0",
"jest-environment-jsdom": "29.7.0",
"jquery": "3.7.1",
"rollup": "4.13.0",
"rollup-plugin-copy": "3.5.0",
"stylelint-config-wikimedia": "0.16.1",
"wdio-mediawiki": "2.3.0",
"webpack": "5.89.0",
"webpack-cli": "5.1.4"
},
"bundlesize": [
{
"path": "resources/dist/main.js",
"maxSize": "110.0kB"
}
]
"wdio-mediawiki": "2.3.0"
}
}

1
resources/dist/codemirror.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
"use strict";var e=require("ext.CodeMirror.v6.lib"),t=require("ext.CodeMirror.v6"),r=require("ext.CodeMirror.v6.mode.mediawiki"),i=function(t){function r(t,i){var o;return e._classCallCheck(this,r),(o=e._callSuper(this,r,[t])).langExtension=i,o.useCodeMirror=mw.user.options.get("usecodemirror")>0,o}return e._inherits(r,t),e._createClass(r,[{key:"setCodeMirrorPreference",value:function(t){this.useCodeMirror=t,e._get(e._getPrototypeOf(r.prototype),"setCodeMirrorPreference",this).call(this,t)}},{key:"enableCodeMirror",value:function(){var t=this;if(!this.view){var r=this.$textarea.prop("selectionStart"),i=this.$textarea.prop("selectionEnd"),o=this.$textarea.scrollTop(),s=this.$textarea.is(":focus"),a=[].concat(e._toConsumableArray(this.defaultExtensions),[this.langExtension,e.EditorView.domEventHandlers({blur:function(){return t.$textarea.triggerHandler("blur")},focus:function(){return t.$textarea.triggerHandler("focus")}}),e.EditorView.lineWrapping]);if(this.initialize(a),requestAnimationFrame((function(){t.view.scrollDOM.scrollTop=o})),0!==r||0!==i){var n=e.EditorSelection.range(r,i),d=e.EditorView.scrollIntoView(n);d.value.isSnapshot=!0,this.view.dispatch({selection:e.EditorSelection.create([n]),effects:d})}s&&this.view.focus(),mw.hook("ext.CodeMirror.switch").fire(!0,$(this.view.dom))}}},{key:"addCodeMirrorToWikiEditor",value:function(){var e=this,t=this.$textarea.data("wikiEditor-context"),r=t&&t.modules&&t.modules.toolbar;r&&(this.$textarea.wikiEditor("addToToolbar",{section:"main",groups:{codemirror:{tools:{CodeMirror:{label:mw.msg("codemirror-toggle-label"),type:"toggle",oouiIcon:"highlight",action:{type:"callback",execute:function(){return e.switchCodeMirror()}}}}}}}),r.$toolbar.find(".tool[rel=CodeMirror]").attr("id","mw-editbutton-codemirror"),this.readOnly&&this.$textarea.data("wikiEditor-context").$ui.addClass("ext-codemirror-readonly"),this.useCodeMirror&&this.enableCodeMirror(),this.updateToolbarButton(),this.logUsage({editor:"wikitext",enabled:this.useCodeMirror,toggled:!1,edit_start_ts_ms:1e3*parseInt($('input[name="wpStarttime"]').val(),10)||0}))}},{key:"updateToolbarButton",value:function(){var e=$("#mw-editbutton-codemirror");e.toggleClass("mw-editbutton-codemirror-active",this.useCodeMirror),e.data("setActive")&&e.data("setActive")(this.useCodeMirror)}},{key:"switchCodeMirror",value:function(){if(this.view){this.setCodeMirrorPreference(!1);var e=this.view.scrollDOM.scrollTop,t=this.view.hasFocus,r=this.view.state.selection.ranges[0],i=r.from,o=r.to;$(this.view.dom).textSelection("unregister"),this.$textarea.textSelection("unregister"),this.$textarea.val(this.view.state.doc.toString()),this.view.destroy(),this.view=null,this.$textarea.show(),t&&this.$textarea.trigger("focus"),this.$textarea.prop("selectionStart",Math.min(i,o)).prop("selectionEnd",Math.max(o,i)),this.$textarea.scrollTop(e),mw.hook("ext.CodeMirror.switch").fire(!1,this.$textarea)}else this.enableCodeMirror(),this.setCodeMirrorPreference(!0);this.updateToolbarButton(),this.logUsage({editor:"wikitext",enabled:this.useCodeMirror,toggled:!0,edit_start_ts_ms:1e3*parseInt($('input[name="wpStarttime"]').val(),10)||0})}}]),r}(t);mw.loader.getState("ext.wikiEditor")&&mw.hook("wikiEditor.toolbarReady").add((function(e){new i(e,r({bidiIsolation:"rtl"===e.attr("dir")})).addCodeMirrorToWikiEditor()}));

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
resources/dist/vendor.js vendored Normal file

File diff suppressed because one or more lines are too long

87
rollup.config.js Normal file
View file

@ -0,0 +1,87 @@
'use strict';
const nodeResolve = require( '@rollup/plugin-node-resolve' );
const copy = require( 'rollup-plugin-copy' );
const babel = require( '@rollup/plugin-babel' );
const terser = require( '@rollup/plugin-terser' );
/**
* Mapping of import paths to ResourceLoader module names.
* See usage in 'plugins' below for explanation.
* @type {Object}
*/
const importAliases = {
'./vendor.js': 'ext.CodeMirror.v6.lib',
'./codemirror.js': 'ext.CodeMirror.v6',
'./codemirror.mode.mediawiki.js': 'ext.CodeMirror.v6.mode.mediawiki'
};
module.exports = [
{
// One entry for each ResourceLoader module that we want to ship.
input: [
'src/codemirror.js',
'src/codemirror.mode.mediawiki.js',
'src/codemirror.wikieditor.mediawiki.js'
],
output: {
entryFileNames: '[name].js',
dir: 'resources/dist',
// Magically makes our ECMAScript Modules work with the
// CommonJS-style preferred by ResourceLoader. Ta-da!
format: 'cjs',
// Remove hash from chunked file name. We only want vendor code to be
// chunked, and we need the file name to be stable for use by ResourceLoader.
chunkFileNames: () => '[name].js',
// Bundle all vendor code into a single file called 'vendor.js'.
// This includes the Babel helpers because they are used by all our modules.
manualChunks: ( id ) => {
if ( id.includes( 'node_modules' ) || id.includes( 'rollupPluginBabelHelpers' ) ) {
return 'vendor';
}
}
},
plugins: [
nodeResolve(),
// HACK: Rollup doesn't know about ResourceLoader and attempts to `require`
// modules using a relative path, when they need to match the RL module name.
// Here we do string replacements to fix that. This is nasty and brittle, but
// otherwise we couldn't offer standalone CodeMirror functionality via RL,
// which is necessary for usage in on-wiki scripts and gadgets (T214989).
copy( {
targets: [ {
src: 'resources/dist/*',
dest: 'resources/dist/',
transform: ( contents ) => {
Object.keys( importAliases ).forEach( ( alias ) => {
contents = contents.toString().replace(
`require("${ alias }")`,
`require("${ importAliases[ alias ] }")`
);
} );
return contents;
}
} ],
hook: 'writeBundle'
} ),
babel( { babelHelpers: 'bundled' } ),
terser()
],
onwarn: ( warning, warn ) => {
// Suppress "not exported" warnings. We import those for IDE support not for the build.
if ( warning.code === 'MISSING_EXPORT' ) {
return;
}
warn( warning );
}
}
];

View file

@ -14,7 +14,6 @@
"commonjs": true
},
"globals": {
"__non_webpack_require__": "readonly",
"Tree": "readonly"
},
"rules": {

View file

@ -5,10 +5,7 @@ import { searchKeymap } from '@codemirror/search';
import { bracketMatching } from '@codemirror/language';
import CodemirrorTextSelection from './codemirror.textSelection';
// Necessary so that `require` doesn't get mangled into `__webpack_require__`,
// which ResourceLoader won't recognize and thus be unable to load the virtual file.
// See https://webpack-v3.jsx.app/api/module-variables/#__non_webpack_require__-webpack-specific-
__non_webpack_require__( '../ext.CodeMirror.data.js' );
require( '../ext.CodeMirror.data.js' );
/**
* @class CodeMirror

View file

@ -1,5 +1,5 @@
const { EditorView } = require( '@codemirror/view' );
const CodeMirror = require( '../../src/codemirror.js' ).default;
import { EditorView } from '@codemirror/view';
import CodeMirror from '../../src/codemirror.js';
const $textarea = $( '<textarea>' ),
cm = new CodeMirror( $textarea );

View file

@ -1,3 +1,4 @@
jest.mock( '../../ext.CodeMirror.data.js', () => jest.fn(), { virtual: true } );
global.mw = require( '@wikimedia/mw-node-qunit/src/mockMediaWiki.js' )();
mw.user = Object.assign( mw.user, {
options: {
@ -12,8 +13,5 @@ mw.user = Object.assign( mw.user, {
mw.config.get = jest.fn().mockReturnValue( '1000+ edits' );
mw.track = jest.fn();
mw.Api.prototype.saveOption = jest.fn();
// eslint-disable-next-line no-underscore-dangle, camelcase
global.__non_webpack_require__ = jest.fn();
global.$ = require( 'jquery' );
$.fn.textSelection = () => {};

View file

@ -1,98 +0,0 @@
'use strict';
/* eslint-env node */
const { CleanWebpackPlugin } = require( 'clean-webpack-plugin' ),
path = require( 'path' ),
distDir = path.resolve( __dirname, 'resources/dist' ),
srcMapExt = '.map.json',
PUBLIC_PATH = '/w/extensions/CodeMirror';
module.exports = ( env, argv ) => ( {
// Apply the rule of silence: https://wikipedia.org/wiki/Unix_philosophy.
stats: {
all: false,
// Output a timestamp when a build completes. Useful when watching files.
builtAt: true,
errors: true,
warnings: true
},
// Fail on the first build error instead of tolerating it for prod builds. This seems to
// correspond to optimization.emitOnErrors.
bail: argv.mode === 'production',
// Specify that all paths are relative the Webpack configuration directory not the current
// working directory.
context: __dirname,
entry: './src/codemirror.wikieditor.mediawiki.js',
module: {
rules: [ {
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
// Beware of https://github.com/babel/babel-loader/issues/690. Changes to browsers require
// manual invalidation.
cacheDirectory: true
}
}
} ]
},
optimization: {
// Don't produce production output when a build error occurs.
emitOnErrors: argv.mode !== 'production',
// Use filenames instead of unstable numerical identifiers for file references. This
// increases the gzipped bundle size some but makes the build products easier to debug and
// appear deterministic. I.e., code changes will only alter the bundle they're packed in
// instead of shifting the identifiers in other bundles.
// https://webpack.js.org/guides/caching/#deterministic-hashes (namedModules replaces NamedModulesPlugin.)
moduleIds: 'named'
},
output: {
// Specify the destination of all build products.
path: distDir,
// Store outputs per module in files named after the modules. For the JavaScript entry
// itself, append .js to each ResourceLoader module entry name. This value is tightly
// coupled to sourceMapFilename.
filename: '[name].js',
// Rename source map extensions. Per T173491 files with a .map extension cannot be served
// from prod.
sourceMapFilename: `[file]${ srcMapExt }`,
devtoolModuleFilenameTemplate: `${ PUBLIC_PATH }/[resource-path]`
},
// Accurate source maps at the expense of build time. The source map is intentionally exposed
// to users via sourceMapFilename for prod debugging. This goes against convention as source
// code is publicly distributed.
devtool: 'source-map',
plugins: [
// Delete the output directory on each build.
new CleanWebpackPlugin( {
cleanOnceBeforeBuildPatterns: [ '**/*', '!.eslintrc.json' ]
} )
],
performance: {
// Size violations for prod builds fail; development builds are unchecked.
hints: argv.mode === 'production' ? 'error' : false,
// Minified uncompressed size limits for chunks / assets and entrypoints. Keep these numbers
// up-to-date and rounded to the nearest 10th of a kibibyte so that code sizing costs are
// well understood. Related to bundlesize minified, gzipped compressed file size tests.
maxAssetSize: 352.0 * 1024,
maxEntrypointSize: 352.0 * 1024,
// The default filter excludes map files, but we rename ours.
assetFilter: ( filename ) => !filename.endsWith( srcMapExt )
}
} );