Tooling: Begin to use webpack for JS code generation

Generate changeListeners via webpack
We now use a build folder to build the JavaScript for
our ResourceLoader modules. This is the first change
in a line of changes.
A source map is provided for debug support.

Bug: T156333
Change-Id: I771843d1ddb4b50adedc3fa53b30c2f1d8a76acb
This commit is contained in:
jdlrobson 2017-01-26 13:31:41 -08:00 committed by joakin
parent fa0426e008
commit 49df4b9572
17 changed files with 603 additions and 127 deletions

View file

@ -3,6 +3,7 @@
"env": { "env": {
"browser": true, "browser": true,
"jquery": true, "jquery": true,
"commonjs": true,
"qunit": true "qunit": true
}, },
"globals": { "globals": {

1
.gitignore vendored
View file

@ -2,3 +2,4 @@
/node_modules/ /node_modules/
/vendor/ /vendor/
/composer.lock /composer.lock
resources/**/*.map

View file

@ -23,8 +23,10 @@ module.exports = function ( grunt ) {
] ]
}, },
all: [ all: [
'build/**',
'resources/ext.popups/*.js', 'resources/ext.popups/*.js',
'resources/ext.popups/**/*.js', 'resources/ext.popups/**/*.js',
'!resources/ext.popups/changeListeners/*.js',
'!docs/**', '!docs/**',
'!node_modules/**' '!node_modules/**'
] ]

View file

@ -1,4 +1,4 @@
( function ( mw, $ ) { ( function ( $ ) {
/** /**
* Creates an instance of the event logging change listener. * Creates an instance of the event logging change listener.
@ -12,7 +12,7 @@
* @param {mw.eventLog.Schema} schema * @param {mw.eventLog.Schema} schema
* @return {ext.popups.ChangeListener} * @return {ext.popups.ChangeListener}
*/ */
mw.popups.changeListeners.eventLogging = function ( boundActions, schema ) { module.exports = function ( boundActions, schema ) {
return function ( _, state ) { return function ( _, state ) {
var eventLogging = state.eventLogging, var eventLogging = state.eventLogging,
event = eventLogging.event; event = eventLogging.event;
@ -25,4 +25,4 @@
}; };
}; };
}( mediaWiki, jQuery ) ); }( jQuery ) );

View file

@ -51,7 +51,7 @@
* @param {Object} boundActions * @param {Object} boundActions
* @return {ext.popups.ChangeListener} * @return {ext.popups.ChangeListener}
*/ */
mw.popups.changeListeners.footerLink = function ( boundActions ) { module.exports = function ( boundActions ) {
var $footerLink; var $footerLink;
return function ( prevState, state ) { return function ( prevState, state ) {

View file

@ -0,0 +1,10 @@
( function ( mw ) {
mw.popups.changeListeners = {
footerLink: require( './footerLink' ),
eventLogging: require( './eventLogging' ),
linkTitle: require( './linkTitle' ),
render: require( './render' ),
settings: require( './settings' ),
syncUserSettings: require( './syncUserSettings' )
};
}( mediaWiki ) );

View file

@ -1,4 +1,4 @@
( function ( mw, $ ) { ( function ( $ ) {
/** /**
* Creates an instance of the link title change listener. * Creates an instance of the link title change listener.
@ -9,7 +9,7 @@
* *
* @return {ext.popups.ChangeListener} * @return {ext.popups.ChangeListener}
*/ */
mw.popups.changeListeners.linkTitle = function () { module.exports = function () {
var title; var title;
/** /**
@ -62,4 +62,4 @@
}; };
}; };
}( mediaWiki, jQuery ) ); }( jQuery ) );

View file

@ -6,7 +6,7 @@
* @param {ext.popups.PreviewBehavior} previewBehavior * @param {ext.popups.PreviewBehavior} previewBehavior
* @return {ext.popups.ChangeListener} * @return {ext.popups.ChangeListener}
*/ */
mw.popups.changeListeners.render = function ( previewBehavior ) { module.exports = function ( previewBehavior ) {
var preview; var preview;
return function ( prevState, state ) { return function ( prevState, state ) {

View file

@ -0,0 +1,44 @@
/**
* Creates an instance of the settings change listener.
*
* @param {Object} boundActions
* @param {Object} render function that renders a jQuery el with the settings
* @return {ext.popups.ChangeListener}
*/
module.exports = function ( boundActions, render ) {
var settings;
return function ( prevState, state ) {
if ( !prevState ) {
// Nothing to do on initialization
return;
}
// Update global modal visibility
if (
prevState.settings.shouldShow === false &&
state.settings.shouldShow === true
) {
// Lazily instantiate the settings UI
if ( !settings ) {
settings = render( boundActions );
settings.appendTo( document.body );
}
// Update the UI settings with the current settings
settings.setEnabled( state.preview.enabled );
settings.show();
} else if (
prevState.settings.shouldShow === true &&
state.settings.shouldShow === false
) {
settings.hide();
}
// Update help visibility
if ( prevState.settings.showHelp !== state.settings.showHelp ) {
settings.toggleHelp( state.settings.showHelp );
}
};
};

View file

@ -0,0 +1,60 @@
/**
* Creates an instance of the user settings sync change listener.
*
* This change listener syncs certain parts of the state tree to user
* settings when they change.
*
* Used for:
*
* * Enabled state: If the previews are enabled or disabled.
* * Preview count: When the user dwells on a link for long enough that
* a preview is shown, then their preview count will be incremented (see
* `mw.popups.reducers.eventLogging`, and is persisted to local storage.
*
* @param {ext.popups.UserSettings} userSettings
* @return {ext.popups.ChangeListener}
*/
module.exports = function ( userSettings ) {
return function ( prevState, state ) {
syncIfChanged(
prevState, state, 'eventLogging', 'previewCount',
userSettings.setPreviewCount
);
syncIfChanged(
prevState, state, 'preview', 'enabled',
userSettings.setIsEnabled
);
};
};
/**
* Given a state tree, reducer and property, safely return the value of the
* property if the reducer and property exist
* @param {Object} state tree
* @param {String} reducer key to access on the state tree
* @param {String} prop key to access on the reducer key of the state tree
* @return {*}
*/
function get( state, reducer, prop ) {
return state[ reducer ] && state[ reducer ][ prop ];
}
/**
* Calls a sync function if the property prop on the property reducer on
* the state trees has changed value.
* @param {Object} prevState
* @param {Object} state
* @param {String} reducer key to access on the state tree
* @param {String} prop key to access on the reducer key of the state tree
* @param {Function} sync function to be called with the newest value if
* changed
*/
function syncIfChanged( prevState, state, reducer, prop, sync ) {
var current = get( state, reducer, prop );
if ( prevState && ( get( prevState, reducer, prop ) !== current ) ) {
sync( current );
}
}

View file

@ -0,0 +1,63 @@
# 4. Use webpack
Date: 02/02/2017
## Status
Accepted
## Context
Discussed by entire team, but predominately Sam Smith, Joaquin Hernandez and
Jon Robson.
As our JavaScript becomes more complex we are making it increasingly difficult
to maintain dependencies via extension.json. Dependencies and file order have
to be managed and every new file creation requires an edit to extension.json.
This slows down development. In Vagrant for instance NTFS file systems
experience slowdown when loading many files.
There are many tools that bundle JavaScript out there that can do this for us.
** Pros **
* mw.popups no longer needs to be exposed as a global object
* Dependency management is no longer a manual process but automated by webpack
* Would allow us to explore template pre-compiling
* More reliable debug via source map support
* For non-MediaWiki developers it should be easier to understand our
development workflow.
**Cons**
* There is now a build step. New developers to the extension may try to
directly edit the distribution files.
* Likely to be more merge conflicts, but this could be addressed by additional
tooling (e.g. post-merge build step)
## Decision
There are various bundlers to choose from, but Webpack was chosen on the basis
that
1) It was easy to switch to another
2) It is popular and well maintained.
3) Many members of the team are familiar with it.
https://medium.com/@tomchentw/why-webpack-is-awesome-9691044b6b8e#.mi0mmz75y
provides a good write up.
## Consequences
While we migrate directory structure is likely to go through a series of
changes. Specifically template loading is likely to change in future.
New JavaScript files should import and export other files via commonjs and
not rely on global variables.
extension.json still needs to be updated to point to modules in MediaWiki
core.
Care should be taken when including node module libraries to ensure they
are not loaded by other extensions.
Developers working on the repository are now required to run `npm run build`
in a pre-commit hook to ensure that the right JavaScript is sent to users.

View file

@ -97,12 +97,7 @@
"resources/ext.popups/reducers/settings.js", "resources/ext.popups/reducers/settings.js",
"resources/ext.popups/changeListener.js", "resources/ext.popups/changeListener.js",
"resources/ext.popups/changeListeners/footerLink.js", "resources/ext.popups/changeListeners/index.js",
"resources/ext.popups/changeListeners/linkTitle.js",
"resources/ext.popups/changeListeners/render.js",
"resources/ext.popups/changeListeners/eventLogging.js",
"resources/ext.popups/changeListeners/syncUserSettings.js",
"resources/ext.popups/changeListeners/settings.js",
"resources/ext.popups/settingsDialog.js", "resources/ext.popups/settingsDialog.js",
"resources/ext.popups/pageVisibility.js", "resources/ext.popups/pageVisibility.js",

View file

@ -1,6 +1,7 @@
{ {
"private": true, "private": true,
"scripts": { "scripts": {
"build": "webpack",
"test": "grunt lint", "test": "grunt lint",
"doc": "jsduck" "doc": "jsduck"
}, },
@ -14,6 +15,7 @@
"grunt-eslint": "19.0.0", "grunt-eslint": "19.0.0",
"grunt-jsonlint": "1.1.0", "grunt-jsonlint": "1.1.0",
"grunt-stylelint": "^0.6.0", "grunt-stylelint": "^0.6.0",
"stylelint-config-wikimedia": "0.3.0" "stylelint-config-wikimedia": "0.3.0",
"webpack": "^1.14.0"
} }
} }

View file

@ -0,0 +1,391 @@
/******/ (function(modules) { // webpackBootstrap
/******/ // The module cache
/******/ var installedModules = {};
/******/
/******/ // The require function
/******/ function __webpack_require__(moduleId) {
/******/
/******/ // Check if module is in cache
/******/ if(installedModules[moduleId])
/******/ return installedModules[moduleId].exports;
/******/
/******/ // Create a new module (and put it into the cache)
/******/ var module = installedModules[moduleId] = {
/******/ exports: {},
/******/ id: moduleId,
/******/ loaded: false
/******/ };
/******/
/******/ // Execute the module function
/******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
/******/
/******/ // Flag the module as loaded
/******/ module.loaded = true;
/******/
/******/ // Return the exports of the module
/******/ return module.exports;
/******/ }
/******/
/******/
/******/ // expose the modules object (__webpack_modules__)
/******/ __webpack_require__.m = modules;
/******/
/******/ // expose the module cache
/******/ __webpack_require__.c = installedModules;
/******/
/******/ // __webpack_public_path__
/******/ __webpack_require__.p = "";
/******/
/******/ // Load entry module and return exports
/******/ return __webpack_require__(0);
/******/ })
/************************************************************************/
/******/ ([
/* 0 */
/***/ function(module, exports, __webpack_require__) {
( function ( mw ) {
mw.popups.changeListeners = {
footerLink: __webpack_require__( 1 ),
eventLogging: __webpack_require__( 2 ),
linkTitle: __webpack_require__( 3 ),
render: __webpack_require__( 4 ),
settings: __webpack_require__( 5 ),
syncUserSettings: __webpack_require__( 6 )
};
}( mediaWiki ) );
/***/ },
/* 1 */
/***/ function(module, exports) {
( function ( mw, $ ) {
/**
* Creates the link element and appends it to the footer element.
*
* The following elements are considered to be the footer element (highest
* priority to lowest):
*
* # `#footer-places`
* # `#f-list`
* # The parent element of `#footer li`, which is either an `ol` or `ul`.
*
* @return {jQuery} The link element
*/
function createFooterLink() {
var $link = $( '<li>' ).append(
$( '<a>' )
.attr( 'href', '#' )
.text( mw.message( 'popups-settings-enable' ).text() )
),
$footer;
// As yet, we don't know whether the link should be visible.
$link.hide();
// From https://en.wikipedia.org/wiki/MediaWiki:Gadget-ReferenceTooltips.js,
// which was written by Yair rand <https://en.wikipedia.org/wiki/User:Yair_rand>.
$footer = $( '#footer-places, #f-list' );
if ( $footer.length === 0 ) {
$footer = $( '#footer li' ).parent();
}
$footer.append( $link );
return $link;
}
/**
* Creates an instance of the footer link change listener.
*
* The change listener covers the following behaviour:
*
* * The "Enable previews" link (the "link") is appended to the footer menu
* (see `createFooterLink` above).
* * When Page Previews are disabled, then the link is shown; otherwise, the
* link is hidden.
* * When the user clicks the link, then the `showSettings` bound action
* creator is called.
*
* @param {Object} boundActions
* @return {ext.popups.ChangeListener}
*/
module.exports = function ( boundActions ) {
var $footerLink;
return function ( prevState, state ) {
if ( $footerLink === undefined ) {
$footerLink = createFooterLink();
$footerLink.click( function ( e ) {
e.preventDefault();
boundActions.showSettings();
} );
}
if ( state.settings.shouldShowFooterLink ) {
$footerLink.show();
} else {
$footerLink.hide();
}
};
};
}( mediaWiki, jQuery ) );
/***/ },
/* 2 */
/***/ function(module, exports) {
( function ( $ ) {
/**
* Creates an instance of the event logging change listener.
*
* When an event is enqueued to be logged it'll be logged using the schema.
* Since it's the responsibility of EventLogging (and the UA) to deliver
* logged events, the `EVENT_LOGGED` is immediately dispatched rather than
* waiting for some indicator of completion.
*
* @param {Object} boundActions
* @param {mw.eventLog.Schema} schema
* @return {ext.popups.ChangeListener}
*/
module.exports = function ( boundActions, schema ) {
return function ( _, state ) {
var eventLogging = state.eventLogging,
event = eventLogging.event;
if ( event ) {
schema.log( $.extend( true, {}, eventLogging.baseData, event ) );
boundActions.eventLogged();
}
};
};
}( jQuery ) );
/***/ },
/* 3 */
/***/ function(module, exports) {
( function ( $ ) {
/**
* Creates an instance of the link title change listener.
*
* While the user dwells on a link, then it becomes the active link. The
* change listener will remove a link's `title` attribute while it's the
* active link.
*
* @return {ext.popups.ChangeListener}
*/
module.exports = function () {
var title;
/**
* Destroys the title attribute of the element, storing its value in local
* state so that it can be restored later (see `restoreTitleAttr`).
*
* @param {Element} el
*/
function destroyTitleAttr( el ) {
var $el = $( el );
// Has the user dwelled on a link? If we've already removed its title
// attribute, then NOOP.
if ( title ) {
return;
}
title = $el.attr( 'title' );
$el.attr( 'title', '' );
}
/**
* Restores the title attribute of the element.
*
* @param {Element} el
*/
function restoreTitleAttr( el ) {
$( el ).attr( 'title', title );
title = undefined;
}
return function ( prevState, state ) {
var hasPrevActiveLink = prevState && prevState.preview.activeLink;
if ( hasPrevActiveLink ) {
// Has the user dwelled on a link immediately after abandoning another
// (remembering that the ABANDON_END action is delayed by
// ~10e2 ms).
if ( prevState.preview.activeLink !== state.preview.activeLink ) {
restoreTitleAttr( prevState.preview.activeLink );
}
}
if ( state.preview.activeLink ) {
destroyTitleAttr( state.preview.activeLink );
}
};
};
}( jQuery ) );
/***/ },
/* 4 */
/***/ function(module, exports) {
( function ( mw ) {
/**
* Creates an instance of the render change listener.
*
* @param {ext.popups.PreviewBehavior} previewBehavior
* @return {ext.popups.ChangeListener}
*/
module.exports = function ( previewBehavior ) {
var preview;
return function ( prevState, state ) {
if ( state.preview.shouldShow && !preview ) {
preview = mw.popups.renderer.render( state.preview.fetchResponse );
preview.show( state.preview.activeEvent, previewBehavior );
} else if ( !state.preview.shouldShow && preview ) {
preview.hide();
preview = undefined;
}
};
};
}( mediaWiki ) );
/***/ },
/* 5 */
/***/ function(module, exports) {
/**
* Creates an instance of the settings change listener.
*
* @param {Object} boundActions
* @param {Object} render function that renders a jQuery el with the settings
* @return {ext.popups.ChangeListener}
*/
module.exports = function ( boundActions, render ) {
var settings;
return function ( prevState, state ) {
if ( !prevState ) {
// Nothing to do on initialization
return;
}
// Update global modal visibility
if (
prevState.settings.shouldShow === false &&
state.settings.shouldShow === true
) {
// Lazily instantiate the settings UI
if ( !settings ) {
settings = render( boundActions );
settings.appendTo( document.body );
}
// Update the UI settings with the current settings
settings.setEnabled( state.preview.enabled );
settings.show();
} else if (
prevState.settings.shouldShow === true &&
state.settings.shouldShow === false
) {
settings.hide();
}
// Update help visibility
if ( prevState.settings.showHelp !== state.settings.showHelp ) {
settings.toggleHelp( state.settings.showHelp );
}
};
};
/***/ },
/* 6 */
/***/ function(module, exports) {
/**
* Creates an instance of the user settings sync change listener.
*
* This change listener syncs certain parts of the state tree to user
* settings when they change.
*
* Used for:
*
* * Enabled state: If the previews are enabled or disabled.
* * Preview count: When the user dwells on a link for long enough that
* a preview is shown, then their preview count will be incremented (see
* `mw.popups.reducers.eventLogging`, and is persisted to local storage.
*
* @param {ext.popups.UserSettings} userSettings
* @return {ext.popups.ChangeListener}
*/
module.exports = function ( userSettings ) {
return function ( prevState, state ) {
syncIfChanged(
prevState, state, 'eventLogging', 'previewCount',
userSettings.setPreviewCount
);
syncIfChanged(
prevState, state, 'preview', 'enabled',
userSettings.setIsEnabled
);
};
};
/**
* Given a state tree, reducer and property, safely return the value of the
* property if the reducer and property exist
* @param {Object} state tree
* @param {String} reducer key to access on the state tree
* @param {String} prop key to access on the reducer key of the state tree
* @return {*}
*/
function get( state, reducer, prop ) {
return state[ reducer ] && state[ reducer ][ prop ];
}
/**
* Calls a sync function if the property prop on the property reducer on
* the state trees has changed value.
* @param {Object} prevState
* @param {Object} state
* @param {String} reducer key to access on the state tree
* @param {String} prop key to access on the reducer key of the state tree
* @param {Function} sync function to be called with the newest value if
* changed
*/
function syncIfChanged( prevState, state, reducer, prop, sync ) {
var current = get( state, reducer, prop );
if ( prevState && ( get( prevState, reducer, prop ) !== current ) ) {
sync( current );
}
}
/***/ }
/******/ ]);
//# sourceMappingURL=index.js.map

View file

@ -1,48 +0,0 @@
( function ( mw ) {
/**
* Creates an instance of the settings change listener.
*
* @param {Object} boundActions
* @param {Object} render function that renders a jQuery el with the settings
* @return {ext.popups.ChangeListener}
*/
mw.popups.changeListeners.settings = function ( boundActions, render ) {
var settings;
return function ( prevState, state ) {
if ( !prevState ) {
// Nothing to do on initialization
return;
}
// Update global modal visibility
if (
prevState.settings.shouldShow === false &&
state.settings.shouldShow === true
) {
// Lazily instantiate the settings UI
if ( !settings ) {
settings = render( boundActions );
settings.appendTo( document.body );
}
// Update the UI settings with the current settings
settings.setEnabled( state.preview.enabled );
settings.show();
} else if (
prevState.settings.shouldShow === true &&
state.settings.shouldShow === false
) {
settings.hide();
}
// Update help visibility
if ( prevState.settings.showHelp !== state.settings.showHelp ) {
settings.toggleHelp( state.settings.showHelp );
}
};
};
}( mediaWiki ) );

View file

@ -1,64 +0,0 @@
( function ( mw ) {
/**
* Creates an instance of the user settings sync change listener.
*
* This change listener syncs certain parts of the state tree to user
* settings when they change.
*
* Used for:
*
* * Enabled state: If the previews are enabled or disabled.
* * Preview count: When the user dwells on a link for long enough that
* a preview is shown, then their preview count will be incremented (see
* `mw.popups.reducers.eventLogging`, and is persisted to local storage.
*
* @param {ext.popups.UserSettings} userSettings
* @return {ext.popups.ChangeListener}
*/
mw.popups.changeListeners.syncUserSettings = function ( userSettings ) {
return function ( prevState, state ) {
syncIfChanged(
prevState, state, 'eventLogging', 'previewCount',
userSettings.setPreviewCount
);
syncIfChanged(
prevState, state, 'preview', 'enabled',
userSettings.setIsEnabled
);
};
};
/**
* Given a state tree, reducer and property, safely return the value of the
* property if the reducer and property exist
* @param {Object} state tree
* @param {String} reducer key to access on the state tree
* @param {String} prop key to access on the reducer key of the state tree
* @return {*}
*/
function get( state, reducer, prop ) {
return state[ reducer ] && state[ reducer ][ prop ];
}
/**
* Calls a sync function if the property prop on the property reducer on
* the state trees has changed value.
* @param {Object} prevState
* @param {Object} state
* @param {String} reducer key to access on the state tree
* @param {String} prop key to access on the reducer key of the state tree
* @param {Function} sync function to be called with the newest value if
* changed
*/
function syncIfChanged( prevState, state, reducer, prop, sync ) {
var current = get( state, reducer, prop );
if ( prevState && ( get( prevState, reducer, prop ) !== current ) ) {
sync( current );
}
}
}( mediaWiki ) );

19
webpack.config.js Normal file
View file

@ -0,0 +1,19 @@
var path = require( 'path' );
var webpack = require( 'webpack' );
var PUBLIC_PATH = '/w/extensions/Popups';
module.exports = {
output: {
// The absolute path to the output directory.
path: path.resolve( __dirname, 'resources/' ),
devtoolModuleFilenameTemplate: PUBLIC_PATH + '/[resource-path]',
// Write each chunk (entries, here) to a file named after the entry, e.g.
// the "index" entry gets written to index.js.
filename: '/[name]/index.js'
},
entry: {
'ext.popups/changeListeners': './build/ext.popups/changeListeners/index.js'
},
devtool: 'source-map'
};