/** * @fileoverview Builds default ("index.html") documents from HTML templates * @author Jeff Parsons (@jeffpar) * @copyright Jeff Parsons 2012-2016 * * This file is part of PCjs, a computer emulation software project at . * * PCjs is free software: you can redistribute it and/or modify it under the terms of the * GNU General Public License as published by the Free Software Foundation, either version 3 * of the License, or (at your option) any later version. * * PCjs is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without * even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with PCjs. If not, * see . * * You are required to include the above copyright notice in every source code file of every * copy or modified version of this work, and to display that copyright notice on every screen * that loads or runs any version of this software (see COPYRIGHT in /modules/shared/lib/defines.js). * * Some PCjs files also attempt to load external resource files, such as character-image files, * ROM files, and disk image files. Those external resource files are not considered part of PCjs * for purposes of the GNU General Public License, and the author does not claim any copyright * as to their contents. */ "use strict"; var fs = require("fs"); var path = require("path"); var glob = require("glob"); /** * @class exports * @property {function(string)} sync */ var HTTPAPI = require("./httpapi"); var defines = require("../../shared/lib/defines"); var DumpAPI = require("../../shared/lib/dumpapi"); var MarkOut = require("../../markout"); var net = require("../../shared/lib/netlib"); var proc = require("../../shared/lib/proclib"); var str = require("../../shared/lib/strlib"); var usr = require("../../shared/lib/usrlib"); /** * @class exports * @property {string} name * @property {string} version * @property {Array.} c1pCSSFiles * @property {Array.} c1pJSFiles * @property {Array.} pcCSSFiles * @property {Array.} pcX86Files * @property {Array.} pc8080Files * @property {Array.} pdp11Files */ var pkg = require("../../../package.json"); /* * fCache controls "index.html" caching; it is true by default and can be overridden using the setOptions() * 'cache' property. */ var fCache = true; /* * fConsole controls console messages; it is false by default and can be overridden using the setOptions() * 'console' property. */ var fConsole = false; /* * fServerDebug controls server-related debug features; it is false by default and can be enabled using the * setOptions() 'debug' property (or from the server's command-line interface using "--debug"). * * This used to be named fDebug, which was fine, but it has been renamed to make the distinction between the * server's debug state (fServerDebug) and the debug state of HTMLOut instances (this.fDebug) clearer. */ var fServerDebug = false; /* * logFile is set by server.js using the setOptions() 'logfile' property, if the server has turned on * logging. This allows us to "mingle" our logConsole() output with the server's log (typically "./logs/node.log"). * * The same "mingled" messages will also appear on the console if fConsole has been turned on as well * (using "--console" from our own command-line interface, or via the setOptions() 'console' property). */ var logFile = null; /* * fPrivate will be set to true to by setOptions() if the server was started with '--private', giving the Node * server an option comparable to Jekyll's site.pcjs.private setting, and triggering the load of "private.js" as * appropriate. Currently, the only (checked-in) use of the private setting is to set the client's PRIVATE global * and trigger the loading of alternate (ie, private) XML files in embed.js. */ var fPrivate = false; /* * fRebuild controls the rebuilding of cached "index.html" files, assuming fCache is true; fRebuild is false * by default, and it can be set for all requests using the setOptions() 'rebuild' property, or for individual * requests using the fRebuild parameter to HTMLOut(). */ var fRebuild = false; /* * fSendDefault determines what happens HTMLOut() determines that an up-to-date default document already exists; * if false (default), HTMLOut() will return null, allowing our Express filter() function pass the request on * as a normal static file request; otherwise, HTMLOut() will load the document and send the contents itself * (this is the mode that the CLI uses). * * TODO: Consider honoring fSendDefault even when generating a new default document, although there may be some * efficiency to sending new documents ourselves, because we don't have to wait for the writeFile() to complete. * * NOTE: I've since changed the fSendDefault from false to true, because I'm running into situations where, at * least for directory URLs with default documents, the Express static file handler is reporting that the static * content is "not modified" (304), which in turn causes Safari to occasionally display blank pages. * * The server (server.js) still has the option (via setOptions) to override this setting, but until the dreaded * "Safari blank page" problem is fully understood and addressed, the server should probably not do that. * * TODO: Understand and properly address the "Safari blank page" problem (which I'm still seeing as of * Safari v7.0.3); some folks claim it's a Safari bug, but I'm not convinced, because similar requests/responses * from Apache don't cause the problem. */ var fSendDefault = true; /* * fSockets is set if the server tells us to enable client-side support. */ var fSockets = false; /* * sServerRoot is the root directory of the web server; it can (and should) be overridden using by the Express * web server using setRoot(). */ var sServerRoot = "/Users/Jeff/Sites/pcjs"; /* * sDefaultFile is the default filename to use for web server directories, and sTemplateFile is the default HTML * template to use. */ var sDefaultFile = "index.html"; var sTemplateFile = "./modules/shared/templates/common.html"; /* * sReadMeFile is the default markdown file to load and convert to HTML when a "readme" token is detected in an * HTML template file; sMachineXMLFile is a fallback file to look for when sReadMeFile doesn't exist. */ var sReadMeFile = "README.md"; var sMachineMDFile = "machine.md"; var sMachineXMLFile = "machine.xml"; var sManifestXMLFile = "manifest.xml"; /* * We need lists of the uncompiled scripts for C1P and PCx86, indexed by machine type, so that if we have * to inject those individual scripts into the current document, all the ordering dependencies will be honored * (which is why processMachines() can't simply enumerate all the .js files in the respective script folder). * * We build these lists from the lists stored in the project's "package.json" file; also, we start with the * complete lists (ie, with "debugger.js" included in the proper sequence) and then filter out the debugger * later if it turns out we don't need it. * * We include any required CSS files in these lists as well, for convenience. CSS files are added just before * the closing tag, and JS files are added just before the closing tag. */ var aMachineFiles = { 'C1P': pkg.c1pCSSFiles.concat(pkg.c1pJSFiles), // will be deprecated when PC6502 becomes available 'PC': pkg.pcCSSFiles.concat(pkg.pcX86Files), // deprecated (same as PCx86) 'PCx86': pkg.pcCSSFiles.concat(pkg.pcX86Files), 'PC8080': pkg.pcCSSFiles.concat(pkg.pc8080Files), 'PDP11': pkg.pcCSSFiles.concat(pkg.pdp11Files) }; var aMachineFileTypes = { 'head': [".css"], // put BOTH ".css" and ".js" here if convertMDMachineLinks() embeds its own scripts 'body': [".js"] }; /* * Since we have a small server-side optimization that assumes any directory entry without an extension is * a directory, this is a list of known exceptions (ie, entries that are NOT directories despite no extension). */ var asNonDirectories = [ "COPYING", "LICENSE", "README", "MAKEFILE", "makefile" ]; /* * A list of plain-text file types that we want the server to serve up with mime-type "text/plain"; * all extensions are lower-cased before being checked. */ var asExtsPlainText = [ "65v", "asm", "bas", "hex", "inc", "lst", "mac", "map", "nasm", "txt" ]; /* * When we're generating file listings for a directory (ie, getDirList()), we exclude * all files/folders in BOTH of the following arrays. However, if someone knows/guesses * the name of a non-listed file that does not appear in the non-served set, we're OK * with serving it to them. * * EXAMPLE: If you put "lib" in the non-listed set, then folders containing a "lib" won't * list it, but if you enter a URL to a "lib", its contents will be listed; whereas if you * put "lib" in the non-served set, then neither it NOR its contents will be listed. * * NOTE: There are some additional non-listed run-time checks we perform, such as files * containing a "-debug" suffix, as well as some non-served run-time checks, like anything * ending with ".sh" or ".php" (although I no longer bother with ".php", since all PHP files * should now be removed from the project). */ var asFilesNonListed = [ // "LICENSE", "Gruntfile.js", "npm-shrinkwrap.json", "package.json", "server.js", "index.html", "notes.md", "README.md", "robots.txt", "machine.xml", "manifest.xml", "cache.manifest" ]; var asExtsNonListed = [ // "json" // let's allow these after all (so that people can download disk images) ]; var asExtsNonServed = [ "sh" ]; var asFilesNonServed = [ "bin", "debug", "grunts", "logs", "node.log", "node_modules", // "tests", // not sure there's any reason to NOT display /tests, now that this is on GitHub "tmp", "users", "users.log", "iisnode", // Azure/IISNode-specific "IISNode.yml", // Azure/IISNode-specific "web.config" // Azure/IISNode-specific ]; /* * Maximum blog entries increased from 20 to 100 for the new blog format. */ var nBlogExcerpts = 100; /** * HTMLOut() * * Load (building or rebuilding as needed) a default HTML document (eg, "index.html") * for the specified directory (which corresponds to req.path). sTemplateFile is the name * of a specific template file to start with, but in most cases, callers will pass null, * which means we'll fall back to sTemplateFile. * * @constructor * @param {string} sPath is a fully-qualified web server directory or file * @param {string|null} sFile is an optional template path (relative to sPath) * @param {boolean} fRebuild (this overrides the module's normal fRebuild setting) * @param {Object} req * @param {function(Error,string)} done */ function HTMLOut(sPath, sFile, fRebuild, req, done) { var i; this.sPath = sPath; this.sDir = sPath.replace("/blog", "/_posts"); this.sFile = sReadMeFile; this.sExt = ((i = this.sPath.lastIndexOf('.')) > 0? this.sPath.substr(i+1) : "").toLowerCase(); if (this.sExt == "md") { this.sFile = path.basename(this.sDir); this.sDir = path.dirname(this.sDir); } this.sTemplateFile = (sFile? path.join(this.sDir, sFile) : path.join(sServerRoot, sTemplateFile)); HTMLOut.logDebug('HTMLOut("' + this.sPath + '", "' + this.sTemplateFile + '", ' + fRebuild + ')'); this.fDebug = (fServerDebug || net.hasParm(net.GORT_COMMAND, net.GORT_DEBUG, req)) && !net.hasParm(net.GORT_COMMAND, net.GORT_RELEASE, req); this.fRebuild = fRebuild; this.req = req; this.done = done; this.sHTML = ""; this.sTemplate = null; this.aTokens = {}; this.fRandomize = false; /* * Since we now pass fDebug to the MarkOut module, which may generate some debug * info in the final output that we wouldn't want to cache, I've changed the behavior * of fDebug to simply never cache, instead of always rebuilding the cache. * * if (this.fDebug) this.fRebuild = true; * * Note that a production server should not need the GORT_REBUILD command, so we accept * it only if fServerDebug is true. */ if (fServerDebug && net.hasParm(net.GORT_COMMAND, net.GORT_REBUILD, req)) { req.query[net.GORT_COMMAND] = undefined; this.fRebuild = true; } /* * Check the global cache setting, as well as the presence of ANY special commands * that we would never want to cache. */ if (!fCache || fServerDebug || net.hasParm(net.GORT_COMMAND, null, req) || net.hasParm(net.REVEAL_COMMAND, null, req)) { this.loadFile(this.sTemplateFile, true); return; } /* * Set the name of the default file (eg, "index.html") we will use to cache the template * after all tokens have been replaced. */ if (this.sExt == "md") { this.sCacheFile = this.sPath.replace(".md", ".html"); } else { this.sCacheFile = path.join(this.sDir, sDefaultFile); } if (this.fRebuild) { HTMLOut.logDebug("HTMLOut(): rebuilding " + this.sCacheFile); this.loadFile(this.sTemplateFile, true); return; } /* * Since caching is allowed, let's see if sDefaultFile has already been built * and is newer than the specified template file. */ var obj = this; fs.stat(this.sCacheFile, function doneStatCacheFile(err, statsIndex) { if (err) { obj.loadFile(obj.sTemplateFile, true); } else { fs.stat(obj.sTemplateFile, function doneStatTemplateFile(err, statsTemplate) { if (!err && statsIndex.mtime.getTime() < statsTemplate.mtime.getTime()) { /* * Since the template has a new timestamp, we're going to load and process it * as a template (so set fTemplate = true); */ obj.loadFile(obj.sTemplateFile, true); } else { /* * If the specified template file can't be accessed, we can either report that as * an error, or display the current sDefaultFile; it seems safer and friendlier to * do the latter, and simply log the missing template error. * * if (err) { * obj.setData(err, null); * } */ if (err) { HTMLOut.logError(err, true); } if (fSendDefault) { /* * Since the cached copy appears to be up-to-date, we can load it, but there's * no need to (re)process it as a template (so set fTemplate = false). */ obj.loadFile(obj.sCacheFile, false); } else { /* * By passing null for the (2nd) data parameter, we're telling the caller to pass * the request on as a static file request. */ obj.done(null, null); } } }); } }); } /** * CLI() provides a command-line interface for the htmlout module * * Usage: * * htmlout --dir=(directory) [--file=(filename)] [--cache] [--console] [--rebuild] * * Arguments: * * --cache turns "index.html" caching on or off; caching is ON by default. * * --console turns diagnostic console messages on or off; they are OFF by default. * * --debug turns internal debug console messages on or off; they are OFF by default. * * --dir specifies a directory relative to the web server's root directory; it must begin with '/' and * will be fully-qualified before being passed to HTMLOut(). * * --file specifies an optional filename (relative to the directory given by --dir) of an alternative HTML * template file; otherwise, sTemplateFile (which is relative to sServerRoot) will be used. * * --rebuild forces any cached version of the resulting HTML to be rebuilt (in other words, we will not read * any cached version of the HTML, but if caching is enabled, we will write a new cached version). * * Examples: * * node modules/htmlout/bin/htmlout --dir=/ --console --rebuild */ HTMLOut.CLI = function() { var args = proc.getArgs(); if (args.argc) { var argv = args.argv; /* * Create a dummy Express req object */ var sDir = argv['dir']; var req = {'path': sDir}; if (argv['debug'] !== undefined) fServerDebug = argv['debug']; /* * Note that we don't provide command-line control over the 'senddef' option, because * that option's only purpose is to force HTMLOut() to load and return the requested * file (and the CLI interface is not a filter function). */ HTMLOut.setOptions({'cache': argv['cache'], 'console': argv['console'], 'senddef': true}); if (fServerDebug) { console.log("args: " + JSON.stringify(argv)); console.log("req: " + JSON.stringify(req)); } if (sDir && sDir.charAt(0) == '/') { sDir = path.join(sServerRoot, sDir); var file = new HTMLOut(sDir, argv['file'], argv['rebuild'], req, function doneHTMLOutCLI(err, s) { if (err) { HTMLOut.logError(err, true); } else { console.log(s); } }); } else { console.log("error: --dir missing or invalid"); } } else { console.log("usage: htmlout [--dir=(directory)] [--file=(filename)] [--rebuild] [--cache=(true|false)] [--console=(true|false)]"); } }; /** * filter(req, res, next) is called by the Express web server to give us a crack at the URL * * If the URL path (req.path) refers to a directory, then we will read a common HTML template and fill * it with the contents of the README.md in that directory, and send the response ourselves. Otherwise, * we pass the request back to Express, via next(), because req.path must either refer to a static file, * which the express.static() middleware will take care of, or a non-existent file, which Express should * handle by returning an error (eg, 404). * * @param {Object} req is an Express request object (http://expressjs.com/api.html#req.params) * @param {Object} res is an Express response object (http://expressjs.com/api.html#res.status) * @param {function()} next is the function to call to finish processing this request (unless WE finish it) */ HTMLOut.filter = function(req, res, next) { HTMLOut.logDebug('HTMLOut.filter("' + req.url + '")'); if (HTTPAPI.redirect(req, res, next)) return; var i; var sPath = path.join(sServerRoot, req.path); var sBaseName = path.basename(req.path); var sBaseExt = ((i = sBaseName.lastIndexOf('.')) > 0? sBaseName.substr(i+1) : "").toLowerCase(); var sTrailingChar = req.path.slice(-1); if (!fServerDebug && !net.hasParm(net.GORT_COMMAND, net.GORT_DEBUG, req)) { if (asExtsNonServed.indexOf(sBaseExt) >= 0 || asFilesNonServed.indexOf(sBaseName) >= 0) { /* * Mimic the error code+message that express.static() displays for non-existent files/folders. */ res.status(404).send("Cannot GET " + req.path); return; } } /* * The Safari "blank page" problem continues to plague us. Our first work-around was for directory * "index.html" documents, which we resolved by setting fSendDefault to true, so that we would always send * it ourselves, along with an "ok" (200) response code, instead of letting the Express next() function * handle it with a "not modified" (304) response code. * * However, the problem also extends to any XML files that we serve to an initial Safari request * (eg, the machine.xml and manifest.xml files that we style as web pages). Safari includes * "Cache-Control max-age=0" in the request, and if the response is "Cache-Control public, max-age=0" * along with a 304 response code, Safari may once again display a blank page. * * This problem appears limited to the initial resource request for a particular URL. When these XML * files are requested by Safari while loading another web page, Safari's caching logic is different * (eg, it doesn't include the same "Cache-Control" setting). */ if (sBaseName == "machine.xml" || sBaseName == "manifest.xml") { var sAgent = req.headers['user-agent']; if (sAgent && sAgent.indexOf("Safari/") >= 0 && sAgent.indexOf("Chrome/") < 0 && sAgent.indexOf("OPR/") < 0) { var sCacheControl = req.headers['cache-control']; if (sCacheControl && sCacheControl.indexOf("max-age=0") >= 0) { HTMLOut.logDebug("HTMLOut.filter(" + sBaseName + "): Safari work-around in progress"); fs.readFile(sPath, {encoding: "utf8"}, function doneReadFileFilter(err, sData) { if (err) { HTMLOut.logError(err); next(); // alternatively: res.status(404).send("Cannot GET " + req.path); } else { /* * HACK: Express may still modify our response, turning our 200 status code into a 304 * and adding an Etag, unless we ALSO change the req.method from "GET" to something else. * Supposedly, we could also use app.disable('etag'), but I'm not sure that would prevent * Express from changing the status code, and I'm tired of testing work-arounds for this * irritating behavior. */ req.method = "NONE"; res.set("Content-Type", "application/xml"); res.status(200).send(sData); } }); return; } } } if (asNonDirectories.indexOf(sBaseName) >= 0 || asExtsPlainText.indexOf(sBaseExt) >= 0) { res.set("Content-Type", "text/plain"); } /* * Next, check for API requests (eg, "/api/v1/dump?disk=/disks/pcx86/dos/ibm/2.00/PCDOS200-DISK1.json&format=img") * * We perform this before the trailing-slash-redirect check below, because we don't require our API endpoints to * have a trailing slash. */ if (HTTPAPI.filterAPI(req, res, next)) return; /* * If sBaseName contains a file extension, I want to save some time by assuming it's NOT a directory. * I simplistically check for a file extension by checking merely for the presence of a period ("dot"). * Obviously, folder names *could* also contain periods, so this optimization works only so long as I promise * to not create any public directories containing periods (well, ignoring folders containing version numbers). * * Conversely, if there is NO period, then I want to assume that it IS a directory, and therefore if the * basename did NOT end with a trailing slash AND I've enabled "strict routing" in Express (which I should * have), then we want to pass this request on to next(), so that the "express-slash" module will get a * crack at the URL and redirect with a trailing slash as appropriate. * * This isn't just a cosmetic issue, because without "strict routing" and the "express-slash" module, * URLs like "http://localhost:8088/devices/pcx86/machine/5150/mda/64kb/debugger" will cause problems for * client-side JavaScript when it tries to do an XMLHttpRequest with a relative filename (eg, "machine.xml"); * that request will fetch the "machine.xml" in the parent directory instead of the "debugger" directory. * * TODO: Verify the problem observed above is NOT a side-effect of some poorly written client-side JavaScript * forming improper paths. * * NOTE: To minimize unnecessary redirects, the getDirList() function should always (try to) generate URLs for * folders with trailing slashes. */ var sDir = sPath; if (sBaseExt == "md") { sDir = sDir.replace("/blog", "/_posts"); } else if (asNonDirectories.indexOf(sBaseName) < 0) { if (sTrailingChar != '/') { HTMLOut.logDebug('HTMLOut.filter("' + sBaseName + '"): passing static file request to next()'); next(); return; } } fs.stat(sDir, function doneStatDirFilter(err, stats) { if (err) { HTMLOut.logError(err); // res.status(404).send(err.message); } else { var fDir = stats.isDirectory(); HTMLOut.logDebug('HTMLOut.filter(): isDirectory("' + sDir + '"): ' + fDir); if (fDir || sBaseExt == "md") { new HTMLOut(sPath, null, fRebuild, req, function doneHTMLOutFilter(err, sData) { if (err) { HTMLOut.logError(err); next(); } else if (!sData) { /* * HTMLOut() has the option of returning null, if it determines we can * simply pass the request (ie, treat it as a static request). * * TODO: Assert that this behavior is consistent with the fSendDefault setting * (fSendDefault should be false). */ HTMLOut.logDebug("HTMLOut.filter(): returned null"); next(); } else { HTMLOut.logDebug("HTMLOut.filter(): returned " + sData.length + " bytes"); /* * HACK: Express may still modify our response, turning our 200 status code into a 304 * and adding an Etag, unless we ALSO change the req.method from "GET" to something else. * Supposedly, we could also use app.disable('etag'), but I'm not sure that would prevent * Express from changing the status code, and I'm tired of testing work-arounds for this * irritating behavior in Safari. */ req.method = "NONE"; res.status(200).send(sData); } }); return; } } next(); }); }; /** * logConsole(s) * * By using this instead of console.log(), we can eliminate the constant checks for fConsole (although * doing those checks might save some unnecessary string concatenation when fConsole is false), and we get * the added benefit of optionally being able to log all our messages to the server's log file. * * @param {string} s * @return {string} */ HTMLOut.logConsole = function(s) { if (fConsole) console.log(s); if (logFile) logFile.write(s + "\n"); return s; }; /** * logDebug(s) * * @param {string} s * @return {string} */ HTMLOut.logDebug = function(s) { if (fServerDebug) HTMLOut.logConsole(s); return s; }; /** * logError(err) conditionally logs an error to the console * * @param {Error} err * @param {boolean} [fForce] * @return {string} the error message that was logged (or that would have been logged had logging been enabled) */ HTMLOut.logError = function(err, fForce) { var sError = ""; if (err) { sError = "HTMLOut error: " + err.message; if (fConsole || fForce) HTMLOut.logConsole(sError); } return sError; }; /** * setOptions(options) is used by the Express web server to set module options * * Supported options include: * * 'cache' fCache * 'console' fConsole * 'debug' fServerDebug * 'logfile' logFile * 'private' fPrivate * 'rebuild' fRebuild * 'senddef' fSendDefault * 'sockets' fSockets * * Note that an option must be explicitly set in order to override the option's default value * (see fCache, fConsole, fServerDebug, fRebuild and fSockets, respectively). * * @param {Object} options */ HTMLOut.setOptions = function(options) { if (options['cache'] !== undefined) { fCache = options['cache']; } if (options['console'] !== undefined) { fConsole = options['console']; } if (options['debug'] !== undefined) { fServerDebug = options['debug']; } if (options['logfile'] !== undefined) { logFile = options['logfile']; HTTPAPI.setLogFile(logFile); } if (options['private'] !== undefined) { fPrivate = options['private']; } if (options['rebuild'] !== undefined) { fRebuild = options['rebuild']; } if (options['senddef'] !== undefined) { fSendDefault = options['senddef']; } if (options['sockets'] !== undefined) { fSockets = options['sockets']; } }; /** * setRoot(sRoot) is used by the Express web server to inform us of its root directory * * NOTE: We can't use __dirname, because every module has its own __dirname, so our __dirname * won't be the same as Express's __dirname. Moreover, the Express web server won't necessarily * be configured to use __dirname as the root. Normally, this should match whatever gets * passed to express.static(). * * @param {string} sRoot */ HTMLOut.setRoot = function(sRoot) { sServerRoot = sRoot; HTTPAPI(HTMLOut, sRoot); }; /* * Object methods */ /** * loadFile() * * @this {HTMLOut} * @param {string} sFile * @param {boolean} fTemplate */ HTMLOut.prototype.loadFile = function(sFile, fTemplate) { var obj = this; HTMLOut.logConsole('HTMLOut.loadFile("' + sFile + '")'); fs.readFile(sFile, {encoding: "utf8"}, function doneLoadFile(err, sData) { obj.setData(err, sData, sFile, fTemplate); }); }; /** * setData(err, sData) * * Records the given HTML template and immediately parses it. * * @this {HTMLOut} * @param {Error} err * @param {string} sData * @param {string} sFile * @param {boolean} fTemplate */ HTMLOut.prototype.setData = function(err, sData, sFile, fTemplate) { if (err) { HTMLOut.logError(err); sData = "unable to read " + sFile; fTemplate = false; } if (!fTemplate || !sData) { this.done(null, sData); return; } /* * Copy the HTML template, and then start finding/replacing tokens. * * We cheat slightly and insert one of those tokens right now, because otherwise * the template file itself would not render correctly in your web browser. */ this.sTemplate = sData.replace("/modules/shared/templates/common.css", "/versions/pcx86//common.css"); this.sHTML = this.sTemplate; /* * But first, let's automatically massage any URLs in the template file. */ var link; var reLinks = /(]*?\shref=)(['"])([^'"]*)(\2[^>]*>)/gi; while ((link = reLinks.exec(this.sTemplate))) { var sReplacement = link[1] + link[2] + net.encodeURL(link[3], this.req, this.fDebug) + link[4]; this.sHTML = this.sHTML.replace(link[0], sReplacement); } var reTokens = /([ \t]*)/gi; this.findTokens(reTokens); }; /** * findTokens() * * @this {HTMLOut} * @param {RegExp} reTokens */ HTMLOut.prototype.findTokens = function(reTokens) { while (true) { var token = reTokens.exec(this.sTemplate); if (!token) break; var sIndent = token[1]; /* * As per the warning in replaceTokens(), we must beware of token characters that have special meaning * when parsed as a regular expression; for example, if there are any opening or closing parentheses in * the token, each must be escaped. */ var sToken = token[0].substr(sIndent.length); sToken = sToken.replace(/([\(\)\*])/g, "\\$1"); if (this.aTokens[sToken] === undefined) { this.aTokens[sToken] = ""; if (HTMLOut.tokenFunctions[token[2]] !== undefined) { var fnToken = HTMLOut.tokenFunctions[token[2]][token[3]]; if (fnToken !== undefined) { this.aTokens[sToken] = null; if (fnToken === null) { this.aTokens[sToken] = undefined; continue; } var aParms = []; if (token[4]) { var aMatch; var reParms = /"(.*?)"/g; while ((aMatch = reParms.exec(token[4]))) { aParms.push(aMatch[1]); } } fnToken.call(this, sToken, sIndent, aParms); } } /* * We could yield here after every newly discovered token, but our templates * are pretty simple, so I doubt finding all of them will take significant time. * * var obj = this; * setImmediate(function() { obj.findTokens(reTokens); }); * return; */ } } this.replaceTokens(); }; /** * replaceTokens() * * @this {HTMLOut} */ HTMLOut.prototype.replaceTokens = function() { var fPending = false; for (var sToken in this.aTokens) { if (!this.aTokens.hasOwnProperty(sToken)) continue; var sReplacement = this.aTokens[sToken]; /* * Skip tokens that have already been replaced. */ if (sReplacement === undefined) { continue; } /* * Unknown (null) tokens are pending replacements, which occur when a template function is waiting * for a callback; the callback is required to call replaceTokens() once the replacement is known, * starting the replacement process over again (eg, see getDirList()). */ if (sReplacement === null) { fPending = true; continue; } // HTMLOut.logDebug('HTMLOut.replaceTokens: replacing "' + sToken + '" with "' + sReplacement + '"'); /* * WARNING: Beware of tokens containing characters that have special meaning within regular * expressions; otherwise, this global search-and-replace will fail in unexpected ways. */ this.sHTML = this.sHTML.replace(new RegExp(sToken, "g"), sReplacement); /* * Mark the token as replaced, by setting it to undefined (it's tempting to simply "delete" it, * but that would modify the object we're iterating over, which would be bad form). */ this.aTokens[sToken] = undefined; } if (!fPending) { /* * Remove any lingering HTML/JavaScript comments and unnecessary scripts */ this.sHTML = this.sHTML.replace(/[ \t]*[\r\n]*/g, ""); if (!this.fRandomize) { this.sHTML = this.sHTML.replace(/[ \t]*\n' + sIndent + '' : ""); }; /** * getRandomString(sIndent) * * Generate a random string of words, purely for entertainment purposes (eg, something in honor of "ADVENT"). * * @this {HTMLOut} * @param {string} [sIndent] * @return {string} */ HTMLOut.prototype.getRandomString = function(sIndent) { var s = ""; var asNouns = ["maze", "passages"]; var asAdjectives = ["little", "twisty|twisting"]; while (asNouns.length) { var cAdjectives = Math.floor(Math.random() * Math.min(2, asAdjectives.length)); while (cAdjectives--) { var iAdjective = Math.floor(Math.random() * asAdjectives.length); var sAdjective = asAdjectives[iAdjective]; var asVariations = sAdjective.split("|"); sAdjective = asVariations[Math.floor(Math.random() * asVariations.length)]; if (s) s += " "; s += sAdjective; asAdjectives.splice(iAdjective, 1); } if (s) s += " "; s += asNouns[0]; asNouns.splice(0, 1); if (asNouns.length) s += " of"; } s = sIndent + '

You are in a ' + s + ', all ' + (Math.floor(Math.random() * 2)? 'alike' : 'different') + '.

\n'; this.fRandomize = true; return s; }; /** * processMachines(aMachines, buildOptions, done) * * At a minimum, each machine object should contain the following properties: * * 'type' (eg, a machine type, such as "C1P", "PCx86", "PC8080", or "PDP11") * 'version' (eg, "1.10", "*" to select the current version, or "uncompiled"; "*" is the default) * 'debugger' (eg, true or false; false is the default) * * @this {HTMLOut} * @param {Array} aMachines is an array of objects containing information about each machine on the current page * @param {Object} buildOptions * @param {function()} done */ HTMLOut.prototype.processMachines = function(aMachines, buildOptions, done) { for (var iMachine = 0; iMachine < aMachines.length; iMachine++) { var infoMachine = aMachines[iMachine]; HTMLOut.logDebug('HTMLOut.processMachines(' + JSON.stringify(infoMachine) + ')'); var sType = infoMachine['type']; var fCompiled = !this.fDebug; var sVersion = infoMachine['version']; if (sVersion === undefined || sVersion == '*') { sVersion = pkg.version; } else { fCompiled = (sVersion != "uncompiled"); } var fDebugger = infoMachine['debugger']; if (fDebugger === undefined) fDebugger = false; // default to no debugger var fNoDebug = !this.fDebug; if (net.hasParm(net.GORT_COMMAND, net.GORT_NODEBUG, this.req)) { fNoDebug = true; fCompiled = false; } var sScriptEmbed = ""; if (infoMachine['func']) { sScriptEmbed = ''; } var asFiles = []; if (fCompiled) { var sScriptName = sType.toLowerCase(); var sScriptFile = sScriptName + (fDebugger? "-dbg" : "") + ".js"; var sScriptFolder = sScriptName == "c1p"? "c1pjs" : (sScriptName.substr(0, 3) == "pdp"? "pdpjs" : sScriptName); asFiles.push("/versions/" + sScriptFolder + "/" + sVersion + "/components.css"); asFiles.push("/versions/" + sScriptFolder + "/" + sVersion + "/" + sScriptFile); this.addFilesToHTML(asFiles, sScriptEmbed); } else if (asFiles = aMachineFiles[sType]) { /* * SIDEBAR: Why the "slice()"? It's a handy way to create a copy of the array, and we need a copy, * because if it turns out we need to "cut out" some of the files below (using splice), we don't want that * affecting the original array. */ asFiles = asFiles.slice(); /* * We need to find the shared "defines.js" source file, because we may need to follow it * with "nodebug.js" and/or "private.js". */ for (var i = 0; i < asFiles.length; i++) { if (asFiles[i].indexOf("shared/lib/defines.js") >= 0) { if (fPrivate) { asFiles.splice(i + 1, 0, asFiles[i].replace("defines.js", "private.js")); } if (fNoDebug) { asFiles.splice(i + 1, 0, asFiles[i].replace("defines.js", "nodebug.js")); } break; } } if (!fDebugger) { /* * Step 1: We need to find the client's "defines.js" source file, and follow it with "nodebugger.js". */ for (i = 0; i < asFiles.length; i++) { if (asFiles[i].indexOf("js/lib/defines.js") >= 0) { asFiles.splice(i + 1, 0, asFiles[i].replace("defines.js", "nodebugger.js")); break; } } /* * Step 2: If there's a "debugger.js" source file in the list of uncompiled files, we need to remove * it, which we do by using the Array splice() method, removing the 1 matching element from the array. */ for (i = 0; i < asFiles.length; i++) { if (asFiles[i].indexOf("/debugger.js") >= 0) { asFiles.splice(i, 1); break; } } } this.addFilesToHTML(asFiles, sScriptEmbed); if (buildOptions.id) { asFiles = []; asFiles.push("/modules/build/lib/build.js"); sScriptEmbed = ''; this.addFilesToHTML(asFiles, sScriptEmbed); } } else { HTMLOut.logDebug('HTMLOut.processMachines(): unrecognized machine type "' + sType + '"'); } } if (done) done(); }; /** * addFilesToHTML(asFiles, sScriptEmbed) * * @this {HTMLOut} * @param {Array.} asFiles is a list of CSS and/or JS files to include in the HTML * @param {string} [sScriptEmbed] is an optional script to embed in the (after any JS files listed above) */ HTMLOut.prototype.addFilesToHTML = function(asFiles, sScriptEmbed) { for (var sTag in aMachineFileTypes) { var aMatch = this.sHTML.match(new RegExp("<" + sTag + ">\n?([ \t]*)([\\s\\S]*?)[\n \t]*", "i")); if (aMatch) { var sTextInsert = ""; var sIndent = aMatch[1]; var sText = aMatch[2]; for (var iExt = 0; iExt < aMachineFileTypes[sTag].length; iExt++) { var sExt = aMachineFileTypes[sTag][iExt]; for (var i = 0; i < asFiles.length; i++) { var sFile = asFiles[i]; /* * If the filenames coming from "package.json" begin with "./", strip the leading period. */ if (sFile.substr(0, 2) == "./") sFile = sFile.substr(1); /* * SIDEBAR: substr(-4) is another way to extract the last 4 characters of a string, * but it's non-standard (eg, early versions of IE didn't support it), so if you want to extract * from the end of a string, using slice() with negative indexes is the safer way to go. */ var sInsert; if (sFile.slice(-sExt.length) == sExt) { if (sExt == ".css") { sInsert = '\n' + sIndent + ''; if (sText.indexOf(sInsert) < 0) { sTextInsert += sInsert; } } else if (sExt == ".js") { sInsert = '\n' + sIndent + ''; if (sText.indexOf(sInsert) < 0) { sTextInsert += sInsert; } } } } } if (sScriptEmbed && sTag == "body") { sTextInsert += '\n' + sIndent + sScriptEmbed; } if (sTextInsert) { this.sHTML = this.sHTML.replace(sText, sText + sTextInsert); } } else { HTMLOut.logError(new Error("missing <" + sTag + "> in HTML template")); } } }; /** * genOnClick(sURL) * * @this {HTMLOut} * @param {string} sURL * @param {string} [sFormat] (default is DumpAPI.FORMAT.IMG) * @return {string} */ HTMLOut.prototype.genOnClick = function(sURL, sFormat) { return " onclick=\"window.location='" + DumpAPI.ENDPOINT + "?" + DumpAPI.QUERY.DISK + "=" + sURL.replace('?', '&') + "&" + DumpAPI.QUERY.FORMAT + "=" + (sFormat || DumpAPI.FORMAT.IMG) + "'; return false;\""; }; /* * Class constants/globals */ HTMLOut.tokenFunctions = { 'pcjs': { 'title': HTMLOut.prototype.getTitle, 'version': HTMLOut.prototype.getVersion, 'path': HTMLOut.prototype.getPath, 'pcpath': HTMLOut.prototype.getPCPath, 'dirlist': HTMLOut.prototype.getDirList, 'manifest': null, 'year': HTMLOut.prototype.getYear, 'default': HTMLOut.prototype.getDefault, 'htmlfile': HTMLOut.prototype.getHTMLFile, 'sockets': HTMLOut.prototype.getSocketScripts } }; module.exports = HTMLOut;