diff options
-rw-r--r-- | README.md | 2 | ||||
-rw-r--r-- | app.js | 2 | ||||
-rw-r--r-- | app.json | 4 | ||||
-rw-r--r-- | public/js/google-drive-picker.js | 118 | ||||
-rw-r--r-- | public/js/google-drive-upload.js | 267 | ||||
-rw-r--r-- | public/js/index.js | 104 | ||||
-rw-r--r-- | public/js/lib/common/constant.ejs | 2 | ||||
-rw-r--r-- | public/js/lib/config/index.js | 2 | ||||
-rw-r--r-- | public/js/lib/editor/ui-elements.js | 2 | ||||
-rw-r--r-- | public/views/hackmd/header.ejs | 12 | ||||
-rw-r--r-- | webpackBaseConfig.js | 4 |
11 files changed, 3 insertions, 516 deletions
@@ -272,7 +272,7 @@ There are some config settings you need to change in the files below. | ------- | --------- | ----------- | | facebook, twitter, github, gitlab, mattermost, dropbox, google, ldap, saml | environment variables or `config.json` | for signin | | imgur, s3, minio | environment variables or `config.json` | for image upload | -| google drive(`google/apiKey`, `google/clientID`), dropbox(`dropbox/appKey`) | `config.json` | for export and import | +| dropbox(`dropbox/appKey`) | `config.json` | for export and import | ## Third-party integration OAuth callback URLs @@ -33,8 +33,6 @@ var data = { urlpath: config.urlPath, debug: config.debug, version: config.version, - GOOGLE_API_KEY: config.google.clientSecret, - GOOGLE_CLIENT_ID: config.google.clientID, DROPBOX_APP_KEY: config.dropbox.appKey, allowedUploadMimeTypes: config.allowedUploadMimeTypes } @@ -136,10 +136,6 @@ "description": "Google API client secret", "required": false }, - "HMD_GOOGLE_API_KEY": { - "description": "Google API key (for import/export)", - "required": false - }, "HMD_IMGUR_CLIENTID": { "description": "Imgur API client id", "required": false diff --git a/public/js/google-drive-picker.js b/public/js/google-drive-picker.js deleted file mode 100644 index 5006cd25..00000000 --- a/public/js/google-drive-picker.js +++ /dev/null @@ -1,118 +0,0 @@ -/** ! - * Google Drive File Picker Example - * By Daniel Lo Nigro (http://dan.cx/) - */ -(function () { - /** - * Initialise a Google Driver file picker - */ - var FilePicker = window.FilePicker = function (options) { - // Config - this.apiKey = options.apiKey - this.clientId = options.clientId - - // Elements - this.buttonEl = options.buttonEl - - // Events - this.onSelect = options.onSelect - this.buttonEl.on('click', this.open.bind(this)) - - // Disable the button until the API loads, as it won't work properly until then. - this.buttonEl.prop('disabled', true) - - // Load the drive API - window.gapi.client.setApiKey(this.apiKey) - window.gapi.client.load('drive', 'v2', this._driveApiLoaded.bind(this)) - window.google.load('picker', '1', { callback: this._pickerApiLoaded.bind(this) }) - } - - FilePicker.prototype = { - /** - * Open the file picker. - */ - open: function () { - // Check if the user has already authenticated - var token = window.gapi.auth.getToken() - if (token) { - this._showPicker() - } else { - // The user has not yet authenticated with Google - // We need to do the authentication before displaying the Drive picker. - this._doAuth(false, function () { this._showPicker() }.bind(this)) - } - }, - - /** - * Show the file picker once authentication has been done. - * @private - */ - _showPicker: function () { - var accessToken = window.gapi.auth.getToken().access_token - var view = new window.google.picker.DocsView() - view.setMimeTypes('text/markdown,text/html') - view.setIncludeFolders(true) - view.setOwnedByMe(true) - this.picker = new window.google.picker.PickerBuilder() - .enableFeature(window.google.picker.Feature.NAV_HIDDEN) - .addView(view) - .setAppId(this.clientId) - .setOAuthToken(accessToken) - .setCallback(this._pickerCallback.bind(this)) - .build() - .setVisible(true) - }, - - /** - * Called when a file has been selected in the Google Drive file picker. - * @private - */ - _pickerCallback: function (data) { - if (data[window.google.picker.Response.ACTION] === window.google.picker.Action.PICKED) { - var file = data[window.google.picker.Response.DOCUMENTS][0] - var id = file[window.google.picker.Document.ID] - var request = window.gapi.client.drive.files.get({ - fileId: id - }) - request.execute(this._fileGetCallback.bind(this)) - } - }, - /** - * Called when file details have been retrieved from Google Drive. - * @private - */ - _fileGetCallback: function (file) { - if (this.onSelect) { - this.onSelect(file) - } - }, - - /** - * Called when the Google Drive file picker API has finished loading. - * @private - */ - _pickerApiLoaded: function () { - this.buttonEl.prop('disabled', false) - }, - - /** - * Called when the Google Drive API has finished loading. - * @private - */ - _driveApiLoaded: function () { - this._doAuth(true) - }, - - /** - * Authenticate with Google Drive via the Google JavaScript API. - * @private - */ - _doAuth: function (immediate, callback) { - window.gapi.auth.authorize({ - client_id: this.clientId, - scope: 'https://www.googleapis.com/auth/drive.readonly', - immediate: immediate - }, callback || function () {}) - } - } -}()) diff --git a/public/js/google-drive-upload.js b/public/js/google-drive-upload.js deleted file mode 100644 index 6c0e8a62..00000000 --- a/public/js/google-drive-upload.js +++ /dev/null @@ -1,267 +0,0 @@ -/* eslint-env browser, jquery */ -/** - * Helper for implementing retries with backoff. Initial retry - * delay is 1 second, increasing by 2x (+jitter) for subsequent retries - * - * @constructor - */ -var RetryHandler = function () { - this.interval = 1000 // Start at one second - this.maxInterval = 60 * 1000 // Don't wait longer than a minute -} - -/** - * Invoke the function after waiting - * - * @param {function} fn Function to invoke - */ -RetryHandler.prototype.retry = function (fn) { - setTimeout(fn, this.interval) - this.interval = this.nextInterval_() -} - -/** - * Reset the counter (e.g. after successful request.) - */ -RetryHandler.prototype.reset = function () { - this.interval = 1000 -} - -/** - * Calculate the next wait time. - * @return {number} Next wait interval, in milliseconds - * - * @private - */ -RetryHandler.prototype.nextInterval_ = function () { - var interval = this.interval * 2 + this.getRandomInt_(0, 1000) - return Math.min(interval, this.maxInterval) -} - -/** - * Get a random int in the range of min to max. Used to add jitter to wait times. - * - * @param {number} min Lower bounds - * @param {number} max Upper bounds - * @private - */ -RetryHandler.prototype.getRandomInt_ = function (min, max) { - return Math.floor(Math.random() * (max - min + 1) + min) -} - -/** - * Helper class for resumable uploads using XHR/CORS. Can upload any Blob-like item, whether - * files or in-memory constructs. - * - * @example - * var content = new Blob(["Hello world"], {"type": "text/plain"}); - * var uploader = new MediaUploader({ - * file: content, - * token: accessToken, - * onComplete: function(data) { ... } - * onError: function(data) { ... } - * }); - * uploader.upload(); - * - * @constructor - * @param {object} options Hash of options - * @param {string} options.token Access token - * @param {blob} options.file Blob-like item to upload - * @param {string} [options.fileId] ID of file if replacing - * @param {object} [options.params] Additional query parameters - * @param {string} [options.contentType] Content-type, if overriding the type of the blob. - * @param {object} [options.metadata] File metadata - * @param {function} [options.onComplete] Callback for when upload is complete - * @param {function} [options.onProgress] Callback for status for the in-progress upload - * @param {function} [options.onError] Callback if upload fails - */ -var MediaUploader = function (options) { - var noop = function () {} - this.file = options.file - this.contentType = options.contentType || this.file.type || 'application/octet-stream' - this.metadata = options.metadata || { - 'title': this.file.name, - 'mimeType': this.contentType - } - this.token = options.token - this.onComplete = options.onComplete || noop - this.onProgress = options.onProgress || noop - this.onError = options.onError || noop - this.offset = options.offset || 0 - this.chunkSize = options.chunkSize || 0 - this.retryHandler = new RetryHandler() - - this.url = options.url - if (!this.url) { - var params = options.params || {} - params.uploadType = 'resumable' - this.url = this.buildUrl_(options.fileId, params, options.baseUrl) - } - this.httpMethod = options.fileId ? 'PUT' : 'POST' -} - -/** - * Initiate the upload. - */ -MediaUploader.prototype.upload = function () { - var xhr = new XMLHttpRequest() - - xhr.open(this.httpMethod, this.url, true) - xhr.setRequestHeader('Authorization', 'Bearer ' + this.token) - xhr.setRequestHeader('Content-Type', 'application/json') - xhr.setRequestHeader('X-Upload-Content-Length', this.file.size) - xhr.setRequestHeader('X-Upload-Content-Type', this.contentType) - - xhr.onload = function (e) { - if (e.target.status < 400) { - var location = e.target.getResponseHeader('Location') - this.url = location - this.sendFile_() - } else { - this.onUploadError_(e) - } - }.bind(this) - xhr.onerror = this.onUploadError_.bind(this) - xhr.send(JSON.stringify(this.metadata)) -} - -/** - * Send the actual file content. - * - * @private - */ -MediaUploader.prototype.sendFile_ = function () { - var content = this.file - var end = this.file.size - - if (this.offset || this.chunkSize) { - // Only bother to slice the file if we're either resuming or uploading in chunks - if (this.chunkSize) { - end = Math.min(this.offset + this.chunkSize, this.file.size) - } - content = content.slice(this.offset, end) - } - - var xhr = new XMLHttpRequest() - xhr.open('PUT', this.url, true) - xhr.setRequestHeader('Content-Type', this.contentType) - xhr.setRequestHeader('Content-Range', 'bytes ' + this.offset + '-' + (end - 1) + '/' + this.file.size) - xhr.setRequestHeader('X-Upload-Content-Type', this.file.type) - if (xhr.upload) { - xhr.upload.addEventListener('progress', this.onProgress) - } - xhr.onload = this.onContentUploadSuccess_.bind(this) - xhr.onerror = this.onContentUploadError_.bind(this) - xhr.send(content) -} - -/** - * Query for the state of the file for resumption. - * - * @private - */ -MediaUploader.prototype.resume_ = function () { - var xhr = new XMLHttpRequest() - xhr.open('PUT', this.url, true) - xhr.setRequestHeader('Content-Range', 'bytes */' + this.file.size) - xhr.setRequestHeader('X-Upload-Content-Type', this.file.type) - if (xhr.upload) { - xhr.upload.addEventListener('progress', this.onProgress) - } - xhr.onload = this.onContentUploadSuccess_.bind(this) - xhr.onerror = this.onContentUploadError_.bind(this) - xhr.send() -} - -/** - * Extract the last saved range if available in the request. - * - * @param {XMLHttpRequest} xhr Request object - */ -MediaUploader.prototype.extractRange_ = function (xhr) { - var range = xhr.getResponseHeader('Range') - if (range) { - this.offset = parseInt(range.match(/\d+/g).pop(), 10) + 1 - } -} - -/** - * Handle successful responses for uploads. Depending on the context, - * may continue with uploading the next chunk of the file or, if complete, - * invokes the caller's callback. - * - * @private - * @param {object} e XHR event - */ -MediaUploader.prototype.onContentUploadSuccess_ = function (e) { - if (e.target.status === 200 || e.target.status === 201) { - this.onComplete(e.target.response) - } else if (e.target.status === 308) { - this.extractRange_(e.target) - this.retryHandler.reset() - this.sendFile_() - } else { - this.onContentUploadError_(e) - } -} - -/** - * Handles errors for uploads. Either retries or aborts depending - * on the error. - * - * @private - * @param {object} e XHR event - */ -MediaUploader.prototype.onContentUploadError_ = function (e) { - if (e.target.status && e.target.status < 500) { - this.onError(e.target.response) - } else { - this.retryHandler.retry(this.resume_.bind(this)) - } -} - -/** - * Handles errors for the initial request. - * - * @private - * @param {object} e XHR event - */ -MediaUploader.prototype.onUploadError_ = function (e) { - this.onError(e.target.response) // TODO - Retries for initial upload -} - -/** - * Construct a query string from a hash/object - * - * @private - * @param {object} [params] Key/value pairs for query string - * @return {string} query string - */ -MediaUploader.prototype.buildQuery_ = function (params) { - params = params || {} - return Object.keys(params).map(function (key) { - return encodeURIComponent(key) + '=' + encodeURIComponent(params[key]) - }).join('&') -} - -/** - * Build the drive upload URL - * - * @private - * @param {string} [id] File ID if replacing - * @param {object} [params] Query parameters - * @return {string} URL - */ -MediaUploader.prototype.buildUrl_ = function (id, params, baseUrl) { - var url = baseUrl || 'https://www.googleapis.com/upload/drive/v2/files/' - if (id) { - url += id - } - var query = this.buildQuery_(params) - if (query) { - url += '?' + query - } - return url -} - -window.MediaUploader = MediaUploader diff --git a/public/js/index.js b/public/js/index.js index d76a37fe..c6a4f770 100644 --- a/public/js/index.js +++ b/public/js/index.js @@ -30,8 +30,6 @@ import { import { debug, DROPBOX_APP_KEY, - GOOGLE_API_KEY, - GOOGLE_CLIENT_ID, noteid, noteurl, urlpath, @@ -908,29 +906,6 @@ if (DROPBOX_APP_KEY) { ui.toolbar.export.dropbox.hide() } -// check if google api key and client id are set and load scripts -if (GOOGLE_API_KEY && GOOGLE_CLIENT_ID) { - $('<script>') - .attr('type', 'text/javascript') - .attr('src', 'https://www.google.com/jsapi?callback=onGoogleAPILoaded') - .prop('async', true) - .prop('defer', true) - .appendTo('body') -} else { - ui.toolbar.import.googleDrive.hide() - ui.toolbar.export.googleDrive.hide() -} - -function onGoogleAPILoaded () { - $('<script>') - .attr('type', 'text/javascript') - .attr('src', 'https://apis.google.com/js/client:plusone.js?onload=onGoogleClientLoaded') - .prop('async', true) - .prop('defer', true) - .appendTo('body') -} -window.onGoogleAPILoaded = onGoogleAPILoaded - // button actions // share ui.toolbar.publish.attr('href', noteurl + '/publish') @@ -979,53 +954,6 @@ ui.toolbar.export.dropbox.click(function () { } Dropbox.save(options) }) -function uploadToGoogleDrive (accessToken) { - ui.spinner.show() - var filename = renderFilename(ui.area.markdown) + '.md' - var markdown = editor.getValue() - var blob = new Blob([markdown], { - type: 'text/markdown;charset=utf-8' - }) - blob.name = filename - var uploader = new MediaUploader({ - file: blob, - token: accessToken, - onComplete: function (data) { - data = JSON.parse(data) - showMessageModal('<i class="fa fa-cloud-upload"></i> Export to Google Drive', 'Export Complete!', data.alternateLink, 'Click here to view your file', true) - ui.spinner.hide() - }, - onError: function (data) { - showMessageModal('<i class="fa fa-cloud-upload"></i> Export to Google Drive', 'Export Error :(', '', data, false) - ui.spinner.hide() - } - }) - uploader.upload() -} -function googleApiAuth (immediate, callback) { - gapi.auth.authorize( - { - 'client_id': GOOGLE_CLIENT_ID, - 'scope': 'https://www.googleapis.com/auth/drive.file', - 'immediate': immediate - }, callback || function () { }) -} -function onGoogleClientLoaded () { - googleApiAuth(true) - buildImportFromGoogleDrive() -} -window.onGoogleClientLoaded = onGoogleClientLoaded -// export to google drive -ui.toolbar.export.googleDrive.click(function (e) { - var token = gapi.auth.getToken() - if (token) { - uploadToGoogleDrive(token.access_token) - } else { - googleApiAuth(false, function (result) { - uploadToGoogleDrive(result.access_token) - }) - } -}) // export to gist ui.toolbar.export.gist.attr('href', noteurl + '/gist') // export to snippet @@ -1075,38 +1003,6 @@ ui.toolbar.import.dropbox.click(function () { } Dropbox.choose(options) }) -// import from google drive -function buildImportFromGoogleDrive () { - /* eslint-disable no-unused-vars */ - let picker = new FilePicker({ - apiKey: GOOGLE_API_KEY, - clientId: GOOGLE_CLIENT_ID, - buttonEl: ui.toolbar.import.googleDrive, - onSelect: function (file) { - if (file.downloadUrl) { - ui.spinner.show() - var accessToken = gapi.auth.getToken().access_token - $.ajax({ - type: 'GET', - beforeSend: function (request) { - request.setRequestHeader('Authorization', 'Bearer ' + accessToken) - }, - url: file.downloadUrl, - success: function (data) { - if (file.fileExtension === 'html') { parseToEditor(data) } else { replaceAll(data) } - }, - error: function (data) { - showMessageModal('<i class="fa fa-cloud-download"></i> Import from Google Drive', 'Import failed :(', '', data, false) - }, - complete: function () { - ui.spinner.hide() - } - }) - } - } - }) - /* eslint-enable no-unused-vars */ -} // import from gist ui.toolbar.import.gist.click(function () { // na diff --git a/public/js/lib/common/constant.ejs b/public/js/lib/common/constant.ejs index c0963635..a94b815e 100644 --- a/public/js/lib/common/constant.ejs +++ b/public/js/lib/common/constant.ejs @@ -5,6 +5,4 @@ window.version = '<%- version %>' window.allowedUploadMimeTypes = <%- JSON.stringify(allowedUploadMimeTypes) %> -window.GOOGLE_API_KEY = '<%- GOOGLE_API_KEY %>' -window.GOOGLE_CLIENT_ID = '<%- GOOGLE_CLIENT_ID %>' window.DROPBOX_APP_KEY = '<%- DROPBOX_APP_KEY %>' diff --git a/public/js/lib/config/index.js b/public/js/lib/config/index.js index 11e4389f..4758ffe7 100644 --- a/public/js/lib/config/index.js +++ b/public/js/lib/config/index.js @@ -1,5 +1,3 @@ -export const GOOGLE_API_KEY = window.GOOGLE_API_KEY || '' -export const GOOGLE_CLIENT_ID = window.GOOGLE_CLIENT_ID || '' export const DROPBOX_APP_KEY = window.DROPBOX_APP_KEY || '' export const domain = window.domain || '' // domain name diff --git a/public/js/lib/editor/ui-elements.js b/public/js/lib/editor/ui-elements.js index 88a1e3ca..ca06d30c 100644 --- a/public/js/lib/editor/ui-elements.js +++ b/public/js/lib/editor/ui-elements.js @@ -22,13 +22,11 @@ export const getUIElements = () => ({ }, export: { dropbox: $('.ui-save-dropbox'), - googleDrive: $('.ui-save-google-drive'), gist: $('.ui-save-gist'), snippet: $('.ui-save-snippet') }, import: { dropbox: $('.ui-import-dropbox'), - googleDrive: $('.ui-import-google-drive'), gist: $('.ui-import-gist'), snippet: $('.ui-import-snippet'), clipboard: $('.ui-import-clipboard') diff --git a/public/views/hackmd/header.ejs b/public/views/hackmd/header.ejs index e179f171..21b632ce 100644 --- a/public/views/hackmd/header.ejs +++ b/public/views/hackmd/header.ejs @@ -32,13 +32,11 @@ </li> <li role="presentation"><a role="menuitem" class="ui-extra-slide" tabindex="-1" href="#" target="_blank"><i class="fa fa-tv fa-fw"></i> <%= __('Slide Mode') %></a> </li> - <% if((typeof github !== 'undefined' && github) || (typeof dropbox !== 'undefined' && dropbox) || (typeof google !== 'undefined' && google) || (typeof gitlab !== 'undefined' && gitlab && (!gitlab.scope || gitlab.scope === 'api'))) { %> + <% if((typeof github !== 'undefined' && github) || (typeof dropbox !== 'undefined' && dropbox) || (typeof gitlab !== 'undefined' && gitlab && (!gitlab.scope || gitlab.scope === 'api'))) { %> <li class="divider"></li> <li class="dropdown-header"><%= __('Export') %></li> <li role="presentation"><a role="menuitem" class="ui-save-dropbox" tabindex="-1" href="#" target="_self"><i class="fa fa-dropbox fa-fw"></i> Dropbox</a> </li> - <li role="presentation"><a role="menuitem" class="ui-save-google-drive" tabindex="-1" href="#" target="_self"><i class="fa fa-cloud-upload fa-fw"></i> Google Drive</a> - </li> <% if(typeof github !== 'undefined' && github) { %> <li role="presentation"><a role="menuitem" class="ui-save-gist" tabindex="-1" href="#" target="_blank"><i class="fa fa-github fa-fw"></i> Gist</a> </li> @@ -52,8 +50,6 @@ <li class="dropdown-header"><%= __('Import') %></li> <li role="presentation"><a role="menuitem" class="ui-import-dropbox" tabindex="-1" href="#" target="_self"><i class="fa fa-dropbox fa-fw"></i> Dropbox</a> </li> - <li role="presentation"><a role="menuitem" class="ui-import-google-drive" tabindex="-1" href="#" target="_self"><i class="fa fa-cloud-download fa-fw"></i> Google Drive</a> - </li> <li role="presentation"><a role="menuitem" class="ui-import-gist" href="#" data-toggle="modal" data-target="#gistImportModal"><i class="fa fa-github fa-fw"></i> Gist</a> </li> <% if(typeof gitlab !== 'undefined' && gitlab && (!gitlab.scope || gitlab.scope === 'api')) { %> @@ -138,13 +134,11 @@ </li> <li role="presentation"><a role="menuitem" class="ui-extra-slide" tabindex="-1" href="#" target="_blank"><i class="fa fa-tv fa-fw"></i> <%= __('Slide Mode') %></a> </li> - <% if((typeof github !== 'undefined' && github) || (typeof dropbox !== 'undefined' && dropbox) || (typeof google !== 'undefined' && google) || (typeof gitlab !== 'undefined' && gitlab && (!gitlab.scope || gitlab.scope === 'api'))) { %> + <% if((typeof github !== 'undefined' && github) || (typeof dropbox !== 'undefined' && dropbox) || (typeof gitlab !== 'undefined' && gitlab && (!gitlab.scope || gitlab.scope === 'api'))) { %> <li class="divider"></li> <li class="dropdown-header"><%= __('Export') %></li> <li role="presentation"><a role="menuitem" class="ui-save-dropbox" tabindex="-1" href="#" target="_self"><i class="fa fa-dropbox fa-fw"></i> Dropbox</a> </li> - <li role="presentation"><a role="menuitem" class="ui-save-google-drive" tabindex="-1" href="#" target="_self"><i class="fa fa-cloud-upload fa-fw"></i> Google Drive</a> - </li> <% if(typeof github !== 'undefined' && github) { %> <li role="presentation"><a role="menuitem" class="ui-save-gist" tabindex="-1" href="#" target="_blank"><i class="fa fa-github fa-fw"></i> Gist</a> </li> @@ -158,8 +152,6 @@ <li class="dropdown-header"><%= __('Import') %></li> <li role="presentation"><a role="menuitem" class="ui-import-dropbox" tabindex="-1" href="#" target="_self"><i class="fa fa-dropbox fa-fw"></i> Dropbox</a> </li> - <li role="presentation"><a role="menuitem" class="ui-import-google-drive" tabindex="-1" href="#" target="_self"><i class="fa fa-cloud-download fa-fw"></i> Google Drive</a> - </li> <li role="presentation"><a role="menuitem" class="ui-import-gist" href="#" data-toggle="modal" data-target="#gistImportModal"><i class="fa fa-github fa-fw"></i> Gist</a> </li> <% if(typeof gitlab !== 'undefined' && gitlab && (!gitlab.scope || gitlab.scope === 'api')) { %> diff --git a/webpackBaseConfig.js b/webpackBaseConfig.js index 793308ea..2c6a56f6 100644 --- a/webpackBaseConfig.js +++ b/webpackBaseConfig.js @@ -208,8 +208,6 @@ module.exports = { 'flowchart.js', 'js-sequence-diagrams', 'expose?RevealMarkdown!reveal-markdown', - path.join(__dirname, 'public/js/google-drive-upload.js'), - path.join(__dirname, 'public/js/google-drive-picker.js'), path.join(__dirname, 'public/js/index.js') ], 'index-styles': [ @@ -266,8 +264,6 @@ module.exports = { 'script!abcjs', 'expose?io!socket.io-client', 'expose?RevealMarkdown!reveal-markdown', - path.join(__dirname, 'public/js/google-drive-upload.js'), - path.join(__dirname, 'public/js/google-drive-picker.js'), path.join(__dirname, 'public/js/index.js') ], pretty: [ |