diff --git a/.eslintignore b/.eslintignore index 3f8aa85e46..f46a3257d5 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,5 +1,8 @@ *.min.* node_modules + +packages/cli/src/lib/live-server/* + packages/core/src/lib/markdown-it/patches/* packages/core/src/lib/markdown-it/plugins/* !packages/core/src/lib/markdown-it/plugins/markdown-it-icons.js diff --git a/docs/userGuide/cliCommands.md b/docs/userGuide/cliCommands.md index d6da128b29..e297728ca5 100644 --- a/docs/userGuide/cliCommands.md +++ b/docs/userGuide/cliCommands.md @@ -82,7 +82,7 @@ Usage: markbind * `-o `, `--one-page `
Serves only a single page from your website **initially**. If `` is not specified, it defaults to `index.md/mbd`.
- * Thereafter, when changes to source files have been made, only the page being viewed will be rebuilt if it was affected.
+ * Thereafter, when changes to source files have been made, the opened pages will be rebuilt if it was affected.
* Navigating to a new page will build the new page, if it has not been built before, or there were some changes to source files that affected it before navigating to it.
* {{ icon_example }} `--one-page guide/index.md` diff --git a/packages/cli/index.js b/packages/cli/index.js index aac678a853..473dce934d 100755 --- a/packages/cli/index.js +++ b/packages/cli/index.js @@ -3,7 +3,6 @@ // Entry file for Markbind project const chokidar = require('chokidar'); const fs = require('fs-extra'); -const liveServer = require('live-server'); const path = require('path'); const program = require('commander'); const Promise = require('bluebird'); @@ -20,6 +19,7 @@ const { LAZY_LOADING_SITE_FILE_NAME, } = require('@markbind/core/src/Site/constants'); +const liveServer = require('./src/lib/live-server'); const cliUtil = require('./src/util/cliUtil'); const logger = require('./src/util/logger'); @@ -139,6 +139,9 @@ program const addHandler = (filePath) => { logger.info(`[${new Date().toLocaleTimeString()}] Reload for file add: ${filePath}`); + logger.info('Synchronizing opened pages list before reload'); + const normalizedActiveUrls = liveServer.getActiveUrls().map(url => fsUtil.removeExtension(url)); + site.changeCurrentOpenedPages(normalizedActiveUrls); Promise.resolve('').then(() => { if (site.isFilepathAPage(filePath) || site.isDependencyOfPage(filePath)) { return site.rebuildSourceFiles(filePath); @@ -151,6 +154,9 @@ program const changeHandler = (filePath) => { logger.info(`[${new Date().toLocaleTimeString()}] Reload for file change: ${filePath}`); + logger.info('Synchronizing opened pages list before reload'); + const normalizedActiveUrls = liveServer.getActiveUrls().map(url => fsUtil.removeExtension(url)); + site.changeCurrentOpenedPages(normalizedActiveUrls); Promise.resolve('').then(() => { if (site.isDependencyOfPage(filePath)) { return site.rebuildAffectedSourceFiles(filePath); @@ -163,6 +169,9 @@ program const removeHandler = (filePath) => { logger.info(`[${new Date().toLocaleTimeString()}] Reload for file deletion: ${filePath}`); + logger.info('Synchronizing opened pages list before reload'); + const normalizedActiveUrls = liveServer.getActiveUrls().map(url => fsUtil.removeExtension(url)); + site.changeCurrentOpenedPages(normalizedActiveUrls); Promise.resolve('').then(() => { if (site.isFilepathAPage(filePath) || site.isDependencyOfPage(filePath)) { return site.rebuildSourceFiles(filePath); diff --git a/packages/cli/package.json b/packages/cli/package.json index d27af5b5a3..bd9a69ebef 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -35,7 +35,7 @@ "figlet": "^1.2.4", "find-up": "^4.1.0", "fs-extra": "^9.0.1", - "live-server": "^1.2.1", + "live-server": "1.2.1", "lodash": "^4.17.15", "winston": "^2.4.4", "winston-daily-rotate-file": "^3.10.0" diff --git a/packages/cli/src/lib/live-server/index.js b/packages/cli/src/lib/live-server/index.js new file mode 100644 index 0000000000..5ab2237c13 --- /dev/null +++ b/packages/cli/src/lib/live-server/index.js @@ -0,0 +1,461 @@ +#!/usr/bin/env node + +/* + * Patch for live-server to expose websocket clients for external use in order to keep track + * of opened tabs. + * + * live-server locally keeps track of opened client websockets in order for it to be able + * to perform live reload whenever there are changes in the watched directory. However, the + * clients list is stored internally. + * + * This patch allows us to gain access to the information that can be gathered with the client + * websockets, which in turn can enables the support for multiple-tab development. + * + * Patch is written against live-server v1.2.1 + * The **only** changes are prefaced with a CHANGED comment + */ + +var fs = require('fs'), + connect = require('connect'), + serveIndex = require('serve-index'), + logger = require('morgan'), + WebSocket = require('faye-websocket'), + path = require('path'), + url = require('url'), + http = require('http'), + send = require('send'), + open = require('opn'), + es = require("event-stream"), + os = require('os'), + chokidar = require('chokidar'); +require('colors'); + +// CHANGED: added absolute path that directs to the live-server directory +const pathToLiveServerDir = path.dirname(require.resolve('live-server')); + +// CHANGED: correctly resolve to the live-server directory +var INJECTED_CODE = fs.readFileSync(path.join(pathToLiveServerDir, "injected.html"), "utf8"); + +// CHANGED: added active tabs property +var LiveServer = { + server: null, + watcher: null, + logLevel: 2, + activeTabs: [] +}; + +function escape(html){ + return String(html) + .replace(/&(?!\w+;)/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +// Based on connect.static(), but streamlined and with added code injecter +function staticServer(root) { + var isFile = false; + try { // For supporting mounting files instead of just directories + isFile = fs.statSync(root).isFile(); + } catch (e) { + if (e.code !== "ENOENT") throw e; + } + return function(req, res, next) { + if (req.method !== "GET" && req.method !== "HEAD") return next(); + var reqpath = isFile ? "" : url.parse(req.url).pathname; + var hasNoOrigin = !req.headers.origin; + var injectCandidates = [ new RegExp("", "i"), new RegExp(""), new RegExp("", "i")]; + var injectTag = null; + + function directory() { + var pathname = url.parse(req.originalUrl).pathname; + res.statusCode = 301; + res.setHeader('Location', pathname + '/'); + res.end('Redirecting to ' + escape(pathname) + '/'); + } + + function file(filepath /*, stat*/) { + var x = path.extname(filepath).toLocaleLowerCase(), match, + possibleExtensions = [ "", ".html", ".htm", ".xhtml", ".php", ".svg" ]; + if (hasNoOrigin && (possibleExtensions.indexOf(x) > -1)) { + // TODO: Sync file read here is not nice, but we need to determine if the html should be injected or not + var contents = fs.readFileSync(filepath, "utf8"); + for (var i = 0; i < injectCandidates.length; ++i) { + match = injectCandidates[i].exec(contents); + if (match) { + injectTag = match[0]; + break; + } + } + + // CHANGED: Added line to create new entry on non-include and non-live-reload requests + const reqUrl = req.originalUrl; + if (!reqUrl.endsWith('._include_.html') && !LiveServer.isLiveReloadRequest(reqUrl)) { + /* + * TODO: Find a way to handle the edge case of a tab that is immediately closed before socket + * establishment happens. Current behaviour is that the tab will remain forever in the list. + * Context: https://github.com/MarkBind/markbind/pull/1513#issuecomment-803025676 + */ + const tabEntry = { + url: reqUrl, + client: undefined, + prevClient: undefined, + isReloading: false, + } + LiveServer.activeTabs.unshift(tabEntry); + } + + if (injectTag === null && LiveServer.logLevel >= 3) { + console.warn("Failed to inject refresh script!".yellow, + "Couldn't find any of the tags ", injectCandidates, "from", filepath); + } + } + } + + function error(err) { + if (err.status === 404) return next(); + next(err); + } + + function inject(stream) { + if (injectTag) { + // We need to modify the length given to browser + var len = INJECTED_CODE.length + res.getHeader('Content-Length'); + res.setHeader('Content-Length', len); + var originalPipe = stream.pipe; + stream.pipe = function(resp) { + originalPipe.call(stream, es.replace(new RegExp(injectTag, "i"), INJECTED_CODE + injectTag)).pipe(resp); + }; + } + } + + send(req, reqpath, { root: root }) + .on('error', error) + .on('directory', directory) + .on('file', file) + .on('stream', inject) + .pipe(res); + }; +} + +/** + * Rewrite request URL and pass it back to the static handler. + * @param staticHandler {function} Next handler + * @param file {string} Path to the entry point file + */ +function entryPoint(staticHandler, file) { + if (!file) return function(req, res, next) { next(); }; + + return function(req, res, next) { + req.url = "/" + file; + staticHandler(req, res, next); + }; +} + +/** + * Start a live server with parameters given as an object + * @param host {string} Address to bind to (default: 0.0.0.0) + * @param port {number} Port number (default: 8080) + * @param root {string} Path to root directory (default: cwd) + * @param watch {array} Paths to exclusively watch for changes + * @param ignore {array} Paths to ignore when watching files for changes + * @param ignorePattern {regexp} Ignore files by RegExp + * @param noCssInject Don't inject CSS changes, just reload as with any other file change + * @param open {(string|string[])} Subpath(s) to open in browser, use false to suppress launch (default: server root) + * @param mount {array} Mount directories onto a route, e.g. [['/components', './node_modules']]. + * @param logLevel {number} 0 = errors only, 1 = some, 2 = lots + * @param file {string} Path to the entry point file + * @param wait {number} Server will wait for all changes, before reloading + * @param htpasswd {string} Path to htpasswd file to enable HTTP Basic authentication + * @param middleware {array} Append middleware to stack, e.g. [function(req, res, next) { next(); }]. + */ +LiveServer.start = function(options) { + options = options || {}; + var host = options.host || '0.0.0.0'; + var port = options.port !== undefined ? options.port : 8080; // 0 means random + var root = options.root || process.cwd(); + var mount = options.mount || []; + var watchPaths = options.watch || [root]; + LiveServer.logLevel = options.logLevel === undefined ? 2 : options.logLevel; + var openPath = (options.open === undefined || options.open === true) ? + "" : ((options.open === null || options.open === false) ? null : options.open); + if (options.noBrowser) openPath = null; // Backwards compatibility with 0.7.0 + var file = options.file; + var staticServerHandler = staticServer(root); + var wait = options.wait === undefined ? 100 : options.wait; + var browser = options.browser || null; + var htpasswd = options.htpasswd || null; + var cors = options.cors || false; + var https = options.https || null; + var proxy = options.proxy || []; + var middleware = options.middleware || []; + var noCssInject = options.noCssInject; + var httpsModule = options.httpsModule; + + if (httpsModule) { + try { + require.resolve(httpsModule); + } catch (e) { + console.error(("HTTPS module \"" + httpsModule + "\" you've provided was not found.").red); + console.error("Did you do", "\"npm install " + httpsModule + "\"?"); + return; + } + } else { + httpsModule = "https"; + } + + // Setup a web server + var app = connect(); + + // Add logger. Level 2 logs only errors + if (LiveServer.logLevel === 2) { + app.use(logger('dev', { + skip: function (req, res) { return res.statusCode < 400; } + })); + // Level 2 or above logs all requests + } else if (LiveServer.logLevel > 2) { + app.use(logger('dev')); + } + if (options.spa) { + middleware.push("spa"); + } + // Add middleware + middleware.map(function(mw) { + if (typeof mw === "string") { + var ext = path.extname(mw).toLocaleLowerCase(); + if (ext !== ".js") { + // CHANGED: correctly resolve to the live-server directory + mw = require(path.join(pathToLiveServerDir, "middleware", mw + ".js")); + } else { + mw = require(mw); + } + } + app.use(mw); + }); + + // Use http-auth if configured + if (htpasswd !== null) { + var auth = require('http-auth'); + var basic = auth.basic({ + realm: "Please authorize", + file: htpasswd + }); + app.use(auth.connect(basic)); + } + if (cors) { + app.use(require("cors")({ + origin: true, // reflecting request origin + credentials: true // allowing requests with credentials + })); + } + mount.forEach(function(mountRule) { + var mountPath = path.resolve(process.cwd(), mountRule[1]); + if (!options.watch) // Auto add mount paths to wathing but only if exclusive path option is not given + watchPaths.push(mountPath); + app.use(mountRule[0], staticServer(mountPath)); + if (LiveServer.logLevel >= 1) + console.log('Mapping %s to "%s"', mountRule[0], mountPath); + }); + proxy.forEach(function(proxyRule) { + var proxyOpts = url.parse(proxyRule[1]); + proxyOpts.via = true; + proxyOpts.preserveHost = true; + app.use(proxyRule[0], require('proxy-middleware')(proxyOpts)); + if (LiveServer.logLevel >= 1) + console.log('Mapping %s to "%s"', proxyRule[0], proxyRule[1]); + }); + app.use(staticServerHandler) // Custom static server + .use(entryPoint(staticServerHandler, file)) + .use(serveIndex(root, { icons: true })); + + var server, protocol; + if (https !== null) { + var httpsConfig = https; + if (typeof https === "string") { + httpsConfig = require(path.resolve(process.cwd(), https)); + } + server = require(httpsModule).createServer(httpsConfig, app); + protocol = "https"; + } else { + server = http.createServer(app); + protocol = "http"; + } + + // Handle server startup errors + server.addListener('error', function(e) { + if (e.code === 'EADDRINUSE') { + var serveURL = protocol + '://' + host + ':' + port; + console.log('%s is already in use. Trying another port.'.yellow, serveURL); + setTimeout(function() { + server.listen(0, host); + }, 1000); + } else { + console.error(e.toString().red); + LiveServer.shutdown(); + } + }); + + // Handle successful server + server.addListener('listening', function(/*e*/) { + LiveServer.server = server; + + var address = server.address(); + var serveHost = address.address === "0.0.0.0" ? "127.0.0.1" : address.address; + var openHost = host === "0.0.0.0" ? "127.0.0.1" : host; + + var serveURL = protocol + '://' + serveHost + ':' + address.port; + var openURL = protocol + '://' + openHost + ':' + address.port; + + var serveURLs = [ serveURL ]; + if (LiveServer.logLevel > 2 && address.address === "0.0.0.0") { + var ifaces = os.networkInterfaces(); + serveURLs = Object.keys(ifaces) + .map(function(iface) { + return ifaces[iface]; + }) + // flatten address data, use only IPv4 + .reduce(function(data, addresses) { + addresses.filter(function(addr) { + return addr.family === "IPv4"; + }).forEach(function(addr) { + data.push(addr); + }); + return data; + }, []) + .map(function(addr) { + return protocol + "://" + addr.address + ":" + address.port; + }); + } + + // Output + if (LiveServer.logLevel >= 1) { + if (serveURL === openURL) + if (serveURLs.length === 1) { + console.log(("Serving \"%s\" at %s").green, root, serveURLs[0]); + } else { + console.log(("Serving \"%s\" at\n\t%s").green, root, serveURLs.join("\n\t")); + } + else + console.log(("Serving \"%s\" at %s (%s)").green, root, openURL, serveURL); + } + + // Launch browser + if (openPath !== null) + if (typeof openPath === "object") { + openPath.forEach(function(p) { + open(openURL + p, {app: browser}); + }); + } else { + open(openURL + openPath, {app: browser}); + } + }); + + // Setup server to listen at port + server.listen(port, host); + + // WebSocket + // CHANGED: Removed local clients variable in favour of the clients in active tabs entries + server.addListener('upgrade', function(request, socket, head) { + var ws = new WebSocket(request, socket, head); + ws.onopen = function() { ws.send('connected'); }; + + if (wait > 0) { + (function() { + var wssend = ws.send; + var waitTimeout; + ws.send = function() { + var args = arguments; + if (waitTimeout) clearTimeout(waitTimeout); + waitTimeout = setTimeout(function(){ + wssend.apply(ws, args); + }, wait); + }; + })(); + } + + ws.onclose = function() { + /* + * CHANGED: Modified to remove the active tab that has the closed socket as + * its current client on socket close. In other words, only socket close event that + * does not come from live reload will remove the active tab. + */ + LiveServer.activeTabs = LiveServer.activeTabs.filter(tab => tab.client !== ws); + }; + + // CHANGED: Added line to record tab client socket on creation + const reqUrl = path.dirname(request.url); + const tab = LiveServer.activeTabs.find(tab => tab.url === reqUrl && !tab.client); + tab.client = ws; + tab.isReloading = false; + }); + + var ignored = [ + function(testPath) { // Always ignore dotfiles (important e.g. because editor hidden temp files) + return testPath !== "." && /(^[.#]|(?:__|~)$)/.test(path.basename(testPath)); + } + ]; + if (options.ignore) { + ignored = ignored.concat(options.ignore); + } + if (options.ignorePattern) { + ignored.push(options.ignorePattern); + } + // Setup file watcher + LiveServer.watcher = chokidar.watch(watchPaths, { + ignored: ignored, + ignoreInitial: true + }); + function handleChange(changePath) { + var cssChange = path.extname(changePath) === ".css" && !noCssInject; + if (LiveServer.logLevel >= 1) { + if (cssChange) + console.log("CSS change detected".magenta, changePath); + else console.log("Change detected".cyan, changePath); + } + + // CHANGED: Prepare tab entry data before issuing reload + LiveServer.activeTabs.forEach(tab => { + if (tab.client) { + const client = tab.client; + tab.client = undefined; + tab.prevClient = client; + tab.isReloading = true; + client.send(cssChange ? 'refreshcss' : 'reload'); + } + }); + } + LiveServer.watcher + .on("change", handleChange) + .on("add", handleChange) + .on("unlink", handleChange) + .on("addDir", handleChange) + .on("unlinkDir", handleChange) + .on("ready", function () { + if (LiveServer.logLevel >= 1) + console.log("Ready for changes".cyan); + }) + .on("error", function (err) { + console.log("ERROR:".red, err); + }); + + return server; +}; + +LiveServer.shutdown = function() { + var watcher = LiveServer.watcher; + if (watcher) { + watcher.close(); + } + var server = LiveServer.server; + if (server) + server.close(); +}; + +// CHANGED: Added method to retrieve current active urls +LiveServer.getActiveUrls = () => LiveServer.activeTabs.filter(tab => !tab.isReloading).map(tab => tab.url); + +// CHANGED: Added method to check whether a request is a product of the live reload mechanism +LiveServer.isLiveReloadRequest = (reqUrl) => + LiveServer.activeTabs.some(tab => tab.url === reqUrl && tab.isReloading); + +module.exports = LiveServer; diff --git a/packages/core/src/Site/index.js b/packages/core/src/Site/index.js index 19e6c06942..f667a395a3 100644 --- a/packages/core/src/Site/index.js +++ b/packages/core/src/Site/index.js @@ -144,6 +144,7 @@ class Site { this.currentPageViewed = onePagePath ? path.resolve(this.rootPath, FsUtil.removeExtension(onePagePath)) : ''; + this.currentOpenedPages = []; this.toRebuild = new Set(); } @@ -205,6 +206,29 @@ class Site { return false; } + /** + * Changes the list of current opened pages + * @param {Array} normalizedUrls Collection of normalized url of pages taken from the clients + * ordered from most-to-least recently opened + */ + changeCurrentOpenedPages(normalizedUrls) { + if (!this.onePagePath) { + return; + } + + const openedPages = normalizedUrls.map(normalizedUrl => path.join(this.rootPath, normalizedUrl)); + this.currentOpenedPages = _.uniq(openedPages); + + if (this.currentOpenedPages.length > 0) { + logger.info('Current opened pages, from most-to-least recent:'); + this.currentOpenedPages.forEach((pagePath, idx) => { + logger.info(`${idx + 1}. ${utils.ensurePosix(path.relative(this.rootPath, pagePath))}`); + }); + } else { + logger.info('No pages are currently opened'); + } + } + /** * Read and store the site config from site.json, overwrite the default base URL * if it's specified by the user. @@ -878,16 +902,52 @@ class Site { } /** - * Creates the supplied pages' page generation promises at a throttled rate. - * This is done to avoid pushing too many callbacks into the event loop at once. (#1245) - * @param {Array} pages to generate - * @return {Promise} that resolves once all pages have generated + * Runs the supplied page generation tasks according to the specified mode of each task. + * A page generation task can be a sequential generation or an asynchronous generation. + * @param {Array} pageGenerationTasks Array of page generation tasks + * @return {Promise} A Promise that resolves once all pages have generated */ - generatePagesThrottled(pages) { - const progressBar = new ProgressBar(`[:bar] :current / ${pages.length} pages built`, - { total: pages.length }); + async runPageGenerationTasks(pageGenerationTasks) { + const pagesCount = pageGenerationTasks.reduce((acc, task) => acc + task.pages.length, 0); + const progressBar = new ProgressBar(`[:bar] :current / ${pagesCount} pages built`, { total: pagesCount }); progressBar.render(); + await utils.sequentialAsyncForEach(pageGenerationTasks, async (task) => { + if (task.mode === 'sequential') { + await this.generatePagesSequential(task.pages, progressBar); + } else { + await this.generatePagesAsyncThrottled(task.pages, progressBar); + } + }); + } + + /** + * Generate pages sequentially. That is, the pages are generated + * one-by-one in order. + * @param {Array} pages Pages to be generated + * @param {ProgressBar} progressBar Progress bar of the overall generation process + * @returns {Promise} A Promise that resolves once all pages have been generated + */ + async generatePagesSequential(pages, progressBar) { + await utils.sequentialAsyncForEach(pages, async (page) => { + try { + await page.generate(this.externalManager); + progressBar.tick(); + } catch (err) { + logger.error(err); + throw new Error(`Error while generating ${page.sourcePath}`); + } + }); + } + + /** + * Creates the supplied pages' page generation promises at a throttled rate. + * This is done to avoid pushing too many callbacks into the event loop at once. (#1245) + * @param {Array} pages Pages to be generated + * @param {ProgressBar} progressBar Progress bar of the overall generation process + * @return {Promise} A Promise that resolves once all pages have been generated + */ + generatePagesAsyncThrottled(pages, progressBar) { return new Promise((resolve, reject) => { const counter = { numPagesGenerated: 0 }; @@ -913,7 +973,7 @@ class Site { } /** - * Helper function for generatePagesThrottled(). + * Helper function for generatePagesAsyncThrottled(). */ static generateProgressBarStatus(progressBar, counter, pageGenerationQueue, pages, resolve) { progressBar.tick(); @@ -939,7 +999,11 @@ class Site { this._setTimestampVariable(); this.mapAddressablePagesToPages(addressablePages, faviconUrl); - return this.generatePagesThrottled(this.pages); + const pageGenerationTask = { + mode: 'async', + pages: this.pages, + }; + return this.runPageGenerationTasks([pageGenerationTask]); } /** @@ -968,18 +1032,24 @@ class Site { logger.warn('Rebuilding all pages as variables file was changed, or the --force-reload flag was set'); } this._setTimestampVariable(); - const pagesToRegenerate = this.pages.filter((page) => { + + let openedPagesToRegenerate = []; + const asyncPagesToRegenerate = this.pages.filter((page) => { const doFilePathsHaveSourceFiles = filePaths.some(filePath => page.isDependency(filePath)); if (shouldRebuildAllPages || doFilePathsHaveSourceFiles) { if (this.onePagePath) { const normalizedSource = FsUtil.removeExtension(page.pageConfig.sourcePath); - const isPageBeingViewed = normalizedSource === this.currentPageViewed; + const openIdx = this.currentOpenedPages.findIndex(pagePath => pagePath === normalizedSource); + const isRecentlyViewed = openIdx !== -1; - if (!isPageBeingViewed) { + if (!isRecentlyViewed) { this.toRebuild.add(normalizedSource); - return false; + } else { + openedPagesToRegenerate[openIdx] = page; } + + return false; } return true; @@ -987,15 +1057,39 @@ class Site { return false; }); - if (!pagesToRegenerate.length) { + + /* + * As a side effect of doing assignment to an empty array, some elements might be + * undefined if it has not been assigned to anything. We filter those out here. + */ + openedPagesToRegenerate = openedPagesToRegenerate.filter(page => page); + + const totalPagesToRegenerate = openedPagesToRegenerate.length + asyncPagesToRegenerate.length; + if (totalPagesToRegenerate === 0) { logger.info('No pages needed to be rebuilt'); return; } + logger.info(`Rebuilding ${totalPagesToRegenerate} pages`); - logger.info(`Rebuilding ${pagesToRegenerate.length} pages`); + const pageGenerationTasks = []; + if (openedPagesToRegenerate.length > 0) { + const recentPagesGenerationTask = { + mode: 'sequential', + pages: openedPagesToRegenerate, + }; + pageGenerationTasks.push(recentPagesGenerationTask); + } + + if (asyncPagesToRegenerate.length > 0) { + const asyncPagesGenerationTask = { + mode: 'async', + pages: asyncPagesToRegenerate, + }; + pageGenerationTasks.push(asyncPagesGenerationTask); + } try { - await this.generatePagesThrottled(pagesToRegenerate); + await this.runPageGenerationTasks(pageGenerationTasks); await this.writeSiteData(); logger.info('Pages rebuilt'); this.calculateBuildTimeForRegenerateAffectedPages(startTime); diff --git a/packages/core/src/utils/index.js b/packages/core/src/utils/index.js index 3c4036cdd6..4246c305b9 100644 --- a/packages/core/src/utils/index.js +++ b/packages/core/src/utils/index.js @@ -105,4 +105,21 @@ module.exports = { return text.join('').trim(); }, + + /** + * Applies an asynchronous function for each element in an array. + * Each application evaluation is done sequentially. That is, an asynchronous function + * application of an element is evaluated only when the previous applications + * have finished. + * + * @param {Array} array The array to be iterated over + * @param func The asynchronous function to be applied to each element + * @returns {Promise} A Promise that resolves once every application has been evaluated + */ + async sequentialAsyncForEach(array, func) { + for (let i = 0; i < array.length; i += 1) { + // eslint-disable-next-line no-await-in-loop + await func(array[i]); + } + }, };