8 Commits

Author SHA1 Message Date
2a73816e56 Merge branch 'master' into feature/2-update_data_model 2021-09-05 13:58:28 +00:00
74680ad42b Merge pull request 'Update JS syntax' (#3) from chore/modern_js into master
Reviewed-on: #3
2021-09-05 13:57:09 +00:00
dd1bfd08ea Update data model, documentation
* Port NaturalDocs code documentation to JSDoc
* server --> service,
  server.name --> service.domain,
  server.type --> service.protocol
* Remove ircURI and xmppURI in favor of service.protocol
* Add channel type ("room" or "person")
* Change document path to use service domain instead of custom id/name
2021-09-05 15:56:17 +02:00
e803a1e799 Move source to src directory 2021-09-05 14:05:06 +02:00
54f5d903d0 Update JS syntax 2021-09-04 14:11:37 +02:00
a270d5f9d6 Merge pull request 'Update development environment (Webpack)' (#1) from chore/update_dev_env into master
Reviewed-on: #1
2021-09-04 11:46:25 +00:00
603fd507bf Update development environment (Webpack) 2021-09-04 13:45:15 +02:00
6b95652a76 Update package meta 2021-09-04 12:57:47 +02:00
6 changed files with 2857 additions and 290 deletions

3
dist/build.js vendored

File diff suppressed because one or more lines are too long

2
dist/build.js.map vendored

File diff suppressed because one or more lines are too long

2565
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -4,24 +4,23 @@
"description": "Stores chat messages in daily archive files", "description": "Stores chat messages in daily archive files",
"main": "./dist/build.js", "main": "./dist/build.js",
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1", "build": "NODE_ENV=production webpack",
"dev": "webpack -w", "dev": "webpack -w",
"build": "NODE_ENV=production webpack -p", "start": "npm run dev",
"test": "echo \"Error: no test specified\" && exit 1",
"version": "npm run build && git add dist/" "version": "npm run build && git add dist/"
}, },
"author": "Kosmos Developers <mail@kosmos.org> (https://kosmos.org)", "author": "Kosmos Contributors <mail@kosmos.org> (https://kosmos.org)",
"contributors": [
"Sebastian Kippe <sebastian@kip.pe>"
],
"license": "MIT", "license": "MIT",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/67P/remotestorage-module-chat-messages.git" "url": "https://github.com/67P/remotestorage-module-chat-messages.git"
}, },
"devDependencies": { "devDependencies": {
"babel-core": "^6.18.2", "@babel/core": "^7.14.8",
"babel-loader": "^6.2.7", "@babel/preset-env": "^7.14.9",
"babel-preset-es2015": "^6.18.0", "babel-loader": "^8.2.2",
"webpack": "^1.13.2" "webpack": "^5.48.0",
"webpack-cli": "^4.8.0"
} }
} }

View File

@@ -1,20 +1,35 @@
var ChatMessages = function (privateClient, publicClient) { function pad (num) {
num = String(num);
if (num.length === 1) { num = "0" + num; }
return num;
};
function parseDate (date) {
return {
year: date.getUTCFullYear(),
month: pad( date.getUTCMonth() + 1 ),
day: pad( date.getUTCDate() )
};
};
const ChatMessages = function (privateClient, publicClient) {
/** /**
* Schema: chat-messages/daily * Schema: chat-messages/daily
* *
* Represents one day of chat messages * Represents one calendar day of chat messages
* *
* Example: * @example
*
* (start code)
* { * {
* "@context": "https://kosmos.org/ns/v1", * "@context": "https://kosmos.org/ns/v1",
* "@id": "chat-messages/freenode/channels/kosmos/", * "@id": "chat-messages/irc.libera.chat/channels/kosmos/",
* "@type": "ChatChannel", * "@type": "ChatChannel",
* "service": {
* "domain": "irc.libera.chat",
* "protocol": "IRC",
* },
* "name": "#kosmos", * "name": "#kosmos",
* "ircURI": "irc://irc.freenode.net/kosmos", * "type": "room",
* "today": { * "today": {
* "@id": "2015/01/01", * "@id": "2015/01/01",
* "@type": "ChatLog", * "@type": "ChatLog",
* "messageType": "InstantMessage", * "messageType": "InstantMessage",
@@ -27,9 +42,7 @@
* ] * ]
* } * }
* } * }
* (end code)
*/ */
const archiveSchema = { const archiveSchema = {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -47,17 +60,27 @@
"default": "ChatChannel", "default": "ChatChannel",
"enum": ["ChatChannel"] "enum": ["ChatChannel"]
}, },
"service": {
"type": "object",
"properties": {
"domain": {
"type": "string",
"required": true
},
"protocol": {
"type": "string",
"required": true
}
}
},
"name": { "name": {
"type": "string", "type": "string",
"required": true "required": true
}, },
"ircURI": { "type": {
"type": "string", "type": "string",
"format": "uri" "required": true,
}, "enum": [ "room", "person" ]
"xmppURI": {
"type": "string",
"format": "uri"
}, },
"today": { "today": {
"type": "object", "type": "object",
@@ -122,179 +145,156 @@
publicClient.declareType("daily-archive", "https://kosmos.org/ns/v1", archiveSchema); publicClient.declareType("daily-archive", "https://kosmos.org/ns/v1", archiveSchema);
/** /**
* Class: DailyArchive * A daily archive stores chat messages by calendar day.
* *
* A daily archive stores IRC messages by day. * @class
*
* Parameters (object):
* server - Chat server info (see <DailyArchive.server>)
* channelName - Name of room/channel
* date - Date of archive day
* isPublic - Store logs in public folder (defaults to false)
* previous - Date of previous log file as YYYY/MM/DD;
* looked up automatically when not given
* next - Date of next log file as YYYY/MM/DD;
* looked up automatically when not given
*
* Example for IRC:
*
* (start code)
* var archive = new chatMessages.DailyArchive({
* server: {
* type: 'irc',
* name: 'freenode',
* ircURI: 'irc://irc.freenode.net'
* },
* channelName: '#kosmos',
* date: new Date(),
* isPublic: true
* });
* (end code)
*
* Example for XMPP:
*
* (start code)
* var archive = new chatMessages.DailyArchive({
* server: {
* type: 'xmpp',
* name: '5apps',
* xmppMUC: 'muc.5apps.com'
* },
* channelName: 'watercooler',
* date: new Date(),
* isPublic: false
* });
* (end code)
*/ */
var DailyArchive = function DailyArchive(options) { class DailyArchive {
// /**
// Defaults * @param {object} options
// * @param {object} options.service
options.isPublic = options.isPublic || false; * @param {string} options.service.protocol - Type of chat service/protocol (e.g. "IRC", "XMPP", "Campfire", "Slack")
* @param {string} options.service.domain - Domain of the chat service (e.g. "irc.libera.chat", "kosmos.chat")
* @param {string} options.channelName - Name of room/channel (e.g. "#kosmos")
* @param {string} [options.channelType] - Type of channel ("room" or "person")
* @param {date} options.date - Date of archive day
* @param {boolean} options.isPublic - Store logs in public folder (defaults to false)
* @param {string} [options.previous] - Date of previous log file as `YYYY/MM/DD`. Looked up automatically when not given
* @param {string} [options.next] - Date of next log file as `YYYY/MM/DD`. looked up automatically when not given
*
* @example
* // IRC archive:
* const archive = new chatMessages.DailyArchive({
* service: {
* protocol: 'IRC',
* domain: 'irc.libera.chat',
* },
* channelName: '#kosmos-dev',
* channelType: 'room',
* date: new Date(),
* isPublic: true
* });
*
* // XMPP archive:
* const archive = new chatMessages.DailyArchive({
* service: {
* protocol: 'XMPP',
* domain: 'kosmos.chat',
* },
* channelName: 'kosmos-dev',
* channelType: 'room',
* date: new Date(),
* isPublic: false
* });
*
*/
constructor (options) {
//
// Defaults
//
options.isPublic = options.isPublic || false;
options.channelType = options.channelType || "room";
// //
// Validate options // Validate options
// //
if (typeof options !== "object") { if (typeof options !== "object") {
throw "options must be an object"; throw "options must be an object";
} }
if (typeof options.server !== "object" || if (typeof options.service !== "object" ||
typeof options.server.type !== "string" || typeof options.service.protocol !== "string" ||
typeof options.server.name !== "string") { typeof options.service.domain !== "string") {
throw "server must be an object containing at least server \"type\" and \"name\""; throw "service must be an object containing at least service \"protocol\" and \"domain\"";
} }
if (typeof options.channelName !== "string") { if (typeof options.channelName !== "string") {
throw "channelName must be a string"; throw "channelName must be a string";
} }
if (!(options.date instanceof Date)) { if (!(options.date instanceof Date)) {
throw "date must be a date object"; throw "date must be a date object";
} }
if (typeof options.isPublic !== "boolean") { if (typeof options.isPublic !== "boolean") {
throw "isPublic must be a boolean value"; throw "isPublic must be a boolean value";
}
/**
* @property {object} service
* @property {string} service.protocol - Type of chat service/protocol (e.g. "IRC", "XMPP", "campfire", "slack")
* @property {string} service.domain - Domain of the chat service (e.g. "irc.libera.chat", "kosmos.chat")
*/
this.service = options.service;
/**
* @property {string} channelName - Name of channel (e.g. "#kosmos")
*/
this.channelName = options.channelName;
/**
* @property {string} channelType - Type of channel ("room" or "person")
*/
this.channelType = options.channelType;
/**
* @property {string} date - Gregorian calendar date of the archive's content
*/
this.date = options.date;
/**
* @property {boolean} isPublic - `true` for public archives, `false` for private ones
*/
this.isPublic = options.isPublic || false;
/**
* @property {object} parsedDate - Contains padded year, month and day of date
* @property {string} year
* @property {string} month
* @property {string} day
*/
this.parsedDate = parseDate(this.date);
/**
* @property {string} dateId - Date string in the form of YYYY/MM/DD
*/
this.dateId = this.parsedDate.year+'/'+this.parsedDate.month+'/'+this.parsedDate.day;
/**
* @property {string} path - Document path of the archive file
*/
if (this.channelType === "room") {
// Normal chatroom
const channelName = this.channelName.replace(/#/,'');
this.path = `${this.service.domain}/channels/${channelName}/${this.dateId}`;
} else {
// User direct message
this.path = `${this.service.domain}/users/${this.channelName}/${this.dateId}`;
}
/**
* @property {object} client - Public or private remoteStorgage.js BaseClient
*/
this.client = this.isPublic ? publicClient : privateClient;
/**
* @property {string} previous - Date of previous log file as YYYY/MM/DD
*/
this.previous = options.previous;
/**
* @property {string} next - Date of next log file as YYYY/MM/DD
*/
this.next = options.next;
} }
/**
* Property: server
*
* Contains information about the chat server/network
*
* Properties:
* type - Type of server/protocol (e.g. "irc", "xmpp", "campfire", "slack")
* name - Shortname/id/alias of network/server (e.g. "freenode", "mycompanyname")
* ircURI - (optional) IRC URI of network (e.g. "irc://irc.freenode.net/")
* xmppMUC - (optional) XMPP MUC service host (e.g. "conference.jabber.org")
*/
this.server = options.server;
/**
* Property: channelName
*
* Name of the IRC channel (e.g. "#kosmos")
*/
this.channelName = options.channelName;
/**
* Property: date
*
* Date of the archive's content
*/
this.date = options.date;
/**
* Property: isPublic
*
* `true` for public archives, `false` for private ones
*/
this.isPublic = options.isPublic;
/**
* Property: parsedDate
*
* Object containing padded year, month and day of date
*/
this.parsedDate = parseDate(this.date);
/**
* Property: dateId
*
* Date string in the form of YYYY/MM/DD
*/
this.dateId = this.parsedDate.year+'/'+this.parsedDate.month+'/'+this.parsedDate.day;
/**
* Property: path
*
* Document path of the archive file
*/
switch (this.server.type) {
case 'irc':
if (this.channelName.match(/^#/)) {
// normal chatroom
var channelName = this.channelName.replace(/^#/,'');
this.path = `${this.server.name}/channels/${channelName}/${this.dateId}`;
} else {
// user direct message
this.path = `${this.server.name}/users/${this.channelName}/${this.dateId}`;
}
break;
default:
this.path = `${this.server.name}/${this.channelName}/${this.dateId}`;
break;
}
/**
* Property: client
*
* Public or private BaseClient, depending on isPublic
*/
this.client = this.isPublic ? publicClient : privateClient;
/**
* Property: previous
*
* Date of previous log file as YYYY/MM/DD
*/
this.previous = options.previous;
/**
* Property: next
*
* Date of next log file as YYYY/MM/DD
*/
this.next = options.next;
};
DailyArchive.prototype = {
/* /*
* Method: addMessage * @param {object} message
* @param {string} message.timestamp - Timestamp of the message
* @param {string} message.from - The sender of the message
* @param {string} message.text - The message itself
* @param {string} message.type - Type of message (one of text, join, leave, action)
* @param {string} [message.id] - Unique ID of message. TODO implement
* *
* Parameters (object): * @returns {Promise}
* timestamp - Timestamp of the message
* from - The sender of the message
* text - The message itself
* type - Type of message (one of text, join, leave, action)
*/ */
addMessage: function addMessage(message) { addMessage (message) {
if (this.isPublic && !this.channelName.match(/^#/)) { if (this.isPublic && !this.channelName.match(/^#/)) {
return Promise.resolve(false); return Promise.resolve(false);
} }
@@ -308,20 +308,18 @@
return this._createDocument(message); return this._createDocument(message);
} }
}); });
}, }
/* /*
* Method: addMessages
*
* Like <addMessage>, but for multiple messages at once. Useful for bulk * Like <addMessage>, but for multiple messages at once. Useful for bulk
* imports of messages. * imports of messages.
* *
* Parameters: * @param {Array} messages - Array of message objects (see params for addMessage)
* messages - Array of message objects (see params for addMessage) * @param {boolean} overwrite - If true, creates a new archive file and overwrites the old one. Defaults to false.
* overwrite - If true, creates a new archive file and overwrites the *
* old one. Defaults to false. * @returns {Promise}
*/ */
addMessages: function addMessage(messages, overwrite) { addMessages (messages, overwrite) {
if (this.isPublic && !this.channelName.match(/^#/)) { if (this.isPublic && !this.channelName.match(/^#/)) {
return Promise.resolve(false); return Promise.resolve(false);
} }
@@ -343,23 +341,25 @@
} }
}); });
} }
}, }
/* /*
* Method: remove
*
* Deletes the entire archive document from storage * Deletes the entire archive document from storage
*
* @returns {Promise}
*/ */
remove: function() { remove () {
return this.client.remove(this.path); return this.client.remove(this.path);
}, }
/* /*
* Method: _updateDocument
*
* Updates and writes an existing archive document * Updates and writes an existing archive document
*
* @returns {Promise}
*
* @private
*/ */
_updateDocument: function(archive, messages) { _updateDocument (archive, messages) {
console.debug('[chat-messages] Updating archive document', archive); console.debug('[chat-messages] Updating archive document', archive);
if (Array.isArray(messages)) { if (Array.isArray(messages)) {
@@ -371,16 +371,18 @@
} }
return this._sync(archive); return this._sync(archive);
}, }
/* /*
* Method: _createDocument
*
* Creates and writes a new archive document * Creates and writes a new archive document
*
* @returns {Promise}
*
* @private
*/ */
_createDocument: function(messages) { _createDocument (messages) {
console.debug('[chat-messages] Creating new archive document'); console.debug('[chat-messages] Creating new archive document');
let archive = this._buildArchiveObject(); const archive = this._buildArchiveObject();
if (Array.isArray(messages)) { if (Array.isArray(messages)) {
messages.forEach((message) => { messages.forEach((message) => {
@@ -405,20 +407,24 @@
return this._sync(archive); return this._sync(archive);
}); });
} }
}, }
/* /*
* Method: _buildArchiveObject
*
* Builds the object to be stored in remote storage * Builds the object to be stored in remote storage
*
* @returns {object}
*
* @private
*/ */
_buildArchiveObject: function() { _buildArchiveObject () {
let roomName = this.channelName.replace(/#/,''); const roomName = this.channelName.replace(/#/,'');
let archive = { const archive = {
"@id": "chat-messages/"+this.server.name+"/channels/"+roomName+"/", "@id": "chat-messages/"+this.service.domain+"/channels/"+roomName+"/",
"@type": "ChatChannel", "@type": "ChatChannel",
"service": this.service,
"name": this.channelName, "name": this.channelName,
"type": this.channelType,
"today": { "today": {
"@id": this.dateId, "@id": this.dateId,
"@type": "ChatLog", "@type": "ChatLog",
@@ -427,31 +433,32 @@
} }
}; };
switch (this.server.type) { switch (this.service.protocol) {
case 'irc': case 'IRC':
if (!this.channelName.match(/^#/)) { if (!this.channelName.match(/^#/)) {
archive["@id"] = "chat-messages/"+this.server.name+"/users/"+this.channelName+"/"; archive["@id"] = "chat-messages/"+this.service.domain+"/users/"+this.channelName+"/";
} }
archive["ircURI"] = this.server.ircURI+"/"+roomName;
break; break;
case 'xmpp': case 'XMPP':
archive["xmppURI"] = `xmpp:${this.channelName}@${this.server.xmppMUC}`; // XMPP-specific adjustments
break; break;
} }
return archive; return archive;
}, }
/* /*
* Method: _updatePreviousArchive
*
* Finds the previous archive document and updates its today.next value * Finds the previous archive document and updates its today.next value
*
* @returns {boolean|Promise}
*
* @private
*/ */
_updatePreviousArchive: function() { _updatePreviousArchive () {
return this._findPreviousArchive().then((archive) => { return this._findPreviousArchive().then((archive) => {
if (typeof archive === 'object' && archive.today) { if (typeof archive === 'object' && archive.today) {
archive.today.next = this.dateId; archive.today.next = this.dateId;
let path = this.path.substring(0, this.path.length-this.dateId.length)+archive.today['@id']; const path = this.path.substring(0, this.path.length-this.dateId.length)+archive.today['@id'];
return this.client.storeObject('daily-archive', path, archive).then(() => { return this.client.storeObject('daily-archive', path, archive).then(() => {
console.debug('[chat-messages] Previous archive written to remote storage', path, archive); console.debug('[chat-messages] Previous archive written to remote storage', path, archive);
@@ -462,60 +469,62 @@
return false; return false;
} }
}); });
}, }
/* /*
* Method: _findPreviousArchive
*
* Returns the previous archive document * Returns the previous archive document
*
* @returns {Promise}
*
* @private
*/ */
_findPreviousArchive: function() { _findPreviousArchive () {
const monthPath = this.path.substring(0, this.path.length-2); const monthPath = this.path.substring(0, this.path.length-2);
const yearPath = this.path.substring(0, this.path.length-5); const yearPath = this.path.substring(0, this.path.length-5);
const basePath = this.path.substring(0, this.path.length-10); const basePath = this.path.substring(0, this.path.length-10);
return this.client.getListing(monthPath).then((listing) => { return this.client.getListing(monthPath).then((listing) => {
let days = Object.keys(listing).map((i) => parseInt(i)).map((i) => { const days = Object.keys(listing).map((i) => parseInt(i)).map((i) => {
return (i < parseInt(this.parsedDate.day)) ? i : null; return (i < parseInt(this.parsedDate.day)) ? i : null;
}).filter(function(i){ return i != null; }); }).filter(function(i){ return i != null; });
if (days.length > 0) { if (days.length > 0) {
let day = pad(Math.max(...days).toString()); const day = pad(Math.max(...days).toString());
return this.client.getObject(monthPath+day); return this.client.getObject(monthPath+day);
} }
// Find last day in previous month // Find last day in previous month
return this.client.getListing(yearPath).then((listing) => { return this.client.getListing(yearPath).then((listing) => {
let months = Object.keys(listing).map((i) => parseInt(i.substr(0,2))).map((i) => { const months = Object.keys(listing).map((i) => parseInt(i.substr(0,2))).map((i) => {
return (i < parseInt(this.parsedDate.month)) ? i : null; return (i < parseInt(this.parsedDate.month)) ? i : null;
}).filter(function(i){ return i != null; }); }).filter(function(i){ return i != null; });
if (months.length > 0) { if (months.length > 0) {
let month = pad(Math.max(...months).toString()); const month = pad(Math.max(...months).toString());
return this.client.getListing(yearPath+month+'/').then((listing) => { return this.client.getListing(yearPath+month+'/').then((listing) => {
let days = Object.keys(listing).map((i) => parseInt(i)); const days = Object.keys(listing).map((i) => parseInt(i));
let day = pad(Math.max(...days).toString()); const day = pad(Math.max(...days).toString());
return this.client.getObject(yearPath+month+'/'+day); return this.client.getObject(yearPath+month+'/'+day);
}); });
} else { } else {
// Find last month and day in previous year // Find last month and day in previous year
return this.client.getListing(basePath).then((listing) => { return this.client.getListing(basePath).then((listing) => {
let years = Object.keys(listing).map((i) => parseInt(i.substr(0,4))).map((i) => { const years = Object.keys(listing).map((i) => parseInt(i.substr(0,4))).map((i) => {
return (i < parseInt(this.parsedDate.year)) ? i : null; return (i < parseInt(this.parsedDate.year)) ? i : null;
}).filter(function(i){ return i != null; }); }).filter(function(i){ return i != null; });
if (years.length > 0) { if (years.length > 0) {
let year = Math.max(...years).toString(); const year = Math.max(...years).toString();
return this.client.getListing(basePath+year+'/').then((listing) => { return this.client.getListing(basePath+year+'/').then((listing) => {
let months = Object.keys(listing).map((i) => parseInt(i.substr(0,2))); const months = Object.keys(listing).map((i) => parseInt(i.substr(0,2)));
let month = pad(Math.max(...months).toString()); const month = pad(Math.max(...months).toString());
return this.client.getListing(basePath+year+'/'+month+'/').then((listing) => { return this.client.getListing(basePath+year+'/'+month+'/').then((listing) => {
let days = Object.keys(listing).map((i) => parseInt(i)); const days = Object.keys(listing).map((i) => parseInt(i));
let day = pad(Math.max(...days).toString()); const day = pad(Math.max(...days).toString());
return this.client.getObject(basePath+year+'/'+month+'/'+day); return this.client.getObject(basePath+year+'/'+month+'/'+day);
}); });
}); });
@@ -526,14 +535,16 @@
} }
}); });
}); });
}, }
/* /*
* Method: _sync
*
* Write archive document * Write archive document
*
* @returns {Promise}
*
* @private
*/ */
_sync: function(obj) { _sync (obj) {
console.debug('[chat-messages] Writing archive object', obj); console.debug('[chat-messages] Writing archive object', obj);
return this.client.storeObject('daily-archive', this.path, obj).then(function(){ return this.client.storeObject('daily-archive', this.path, obj).then(function(){
@@ -546,29 +557,13 @@
} }
}; };
var pad = function(num) { return {
num = String(num); exports: {
if (num.length === 1) { num = "0" + num; } DailyArchive,
return num; privateClient,
publicClient
}
}; };
var parseDate = function(date) {
return {
year: date.getUTCFullYear(),
month: pad( date.getUTCMonth() + 1 ),
day: pad( date.getUTCDate() )
};
};
var exports = {
DailyArchive: DailyArchive,
privateClient: privateClient,
publicClient: publicClient
};
// Return public functions
return { exports: exports };
}; };
export default { name: 'chat-messages', builder: ChatMessages }; export default { name: 'chat-messages', builder: ChatMessages };

View File

@@ -1,24 +1,31 @@
var webpack = require('webpack'); /* global __dirname */
var isProd = (process.env.NODE_ENV === 'production'); const isProd = (process.env.NODE_ENV === 'production');
const path = require('path');
// minimize only in production
var plugins = isProd ? [new webpack.optimize.UglifyJsPlugin({minimize: true })] : []
module.exports = { module.exports = {
entry: './index.js', entry: ['./src/chat-messages.js'],
// source map not in production
devtool: !isProd && 'source-map',
output: { output: {
filename: __dirname + '/dist/build.js', path: path.resolve(__dirname, 'dist'),
libraryTarget: 'umd' filename: 'build.js',
library: 'ChatMessages',
libraryTarget: 'umd',
libraryExport: 'default'
}, },
mode: isProd ? 'production' : 'development',
devtool: isProd ? 'source-map' : 'eval-source-map',
module: { module: {
loaders: [ rules: [
{ test: /\.js$/, exclude: '/node_modules|dist/', loader: 'babel?presets[]=es2015' }, {
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env']
}
}
},
] ]
}, },
resolve: { // plugins: plugins
extensions: ['', '.js']
},
plugins: plugins
}; };