// Packaged asset manager class Bundle extends Map { // Instance fields #isDebug; // True if running in debug mode ///////////////////////// Initialization Methods ////////////////////////// constructor() { super(); this.#isDebug = location.host=="localhost" && location.hash=="#debug"; } /////////////////////////////// Properties //////////////////////////////// // Determine whether debug mode is active get isDebug() { return this.#isDebug; } ///////////////////////////// Public Methods ////////////////////////////// // Insert an asset file add(name, data) { let asset = new Bundle.#Asset(this, name, data); this.set(name, asset); return asset; } // List files with names matching a given prefix list(prefix = "") { prefix = String(prefix); let ret = []; for (let file of this.values()) { if (file.name.startsWith(prefix)) ret.push(file); } return ret.sort((a,b)=>a.name.localeCompare(b.name)); } ///////////////////////////////// Classes ///////////////////////////////// // Packaged asset file static #Asset = class Asset { // Private fields #blobURL; // Cached blob: URL #bundle; // Parent Bundle object #data; // Byte contents #dataURL; // Cached data: URL #mime; // MIME type #name; // Filename #transform; // Transform URLs when not in debug mode ////////////////////////////// Constants ////////////////////////////// // Mime types by file extension static #MIMES = { "html" : "text/html;charset=UTF-8" , "css" : "text/css;charset=UTF-8" , "frag" : "text/plain;charset=UTF-8" , "gif" : "image/gif" , "js" : "text/javascript;charset=UTF-8" , "json" : "application/json;charset=UTF-8", "png" : "image/png" , "svg" : "image/svg+xml;charset=UTF-8" , "txt" : "text/plain;charset=UTF-8" , "vert" : "text/plain;charset=UTF-8" , "wasm" : "application/wasm" , "webp" : "image/webp" , "woff2": "font/woff2" } /////////////////////// Initialization Methods //////////////////////// constructor(bundle, name, data) { // Select the MIME type from the file extension let mime = "." + name; let ext = mime.substring(mime.lastIndexOf(".") + 1).toLowerCase(); mime = Bundle.#Asset.#MIMES[ext] ?? "application/octet-stream"; // Configure instanc fields this.#blobURL = null; this.#bundle = bundle; this.#data = data; this.#dataURL = null; this.#mime = mime; this.#name = name; this.#transform = ext == "css" || ext == "js"; } ///////////////////////////// Properties ////////////////////////////// // Retrieve and potentially cache the blob: URL get blobURL() { if (this.#blobURL == null) { this.#blobURL = URL.createObjectURL( new Blob([this.#urlData()], { type: this.#mime })); } return this.#blobURL; } // Byte contents get data() { return this.#data; } // Retrieve and potentially cache the data: URL get dataURL() { if (this.#dataURL == null) { this.#dataURL = "data:" + this.#mime + ";base64," + btoa( Array.from(this.#urlData()).map(b=>String.fromCodePoint(b)) .join("")); } return this.#dataURL; } // Filename get name() { return this.#name; } // Text contents as UTF-8 get text() { return new TextDecoder().decode(this.#data); } // Produce any suitable URL to fetch this file get url() { // Use the blob: URL in debug mode if (!this.#bundle.isDebug) return this.blobURL; // Resolve the virtual path otherwise let href = location.href.split("/"); href.pop(); return href.join("/") + "/" + this.name; } /////////////////////////// Private Methods /////////////////////////// // Prepare a data buffer for use in a data or blob URL #urlData() { // No need to transform inner URLs if (!this.#transform) return this.#data; // Working variables let regex = /\/\*\*?\*\//g; let ret = []; let src = 0; let text = this.text; // Transform all inner URLs for (;;) { let match = regex.exec(text); // No more inner URLs if (match == null) break; // Locate the URL to transform let end, start; try { start = text.indexOf("\"", match.index); if (start == -1) throw 0; end = text.indexOf("\"", ++start); if (end == -1) throw 0; } catch { throw new Error( "Malformed URL designator.\n" + "File: " + this.name ); } // Working variables let url = text.substring(start, end); let parts = url.split("/"); let stack = []; // Initialize the stack to current path if URL is relative if (parts[0] == "." || parts[0] == "..") { stack = this.name.split("/"); stack.pop(); } // Process the URL path while (parts.length > 1) { let part = parts.shift(); switch (part) { // Current directory--do not modify stack case ".": break; // Parent directory--pop from stack case "..": if (stack.length == 0) { throw new Error( "Stack underflow when parsing URL.\n" + "File: " + this.name + "\n" + "URL: " + url ); } stack.pop(); break; // Child directory--push to stack default: stack.push(part); } } // Compose the resolved filename let filename = stack.concat(parts).join("/"); if (!this.#bundle.has(filename)) { throw new Error( "Referenced file does not exist.\n" + "File: " + this.name + "\n" + "URL: " + url + "\n" + "Path: " + filename ); } // Working variables let file = this.#bundle.get(filename); let newUrl = match[0] == "/**/" ? file.blobURL : file.dataURL; // Append the output text ret.push(text.substring(src, start), newUrl); src = end; } // Incorporate remaining text ret.push(text.substring(src)); return new TextEncoder().encode(ret.join("")); } }; } // Program entry point { // Remove startup