From 7a52ddeba2a68388b544f529d2d92104420f77b0 Mon Sep 17 00:00:00 2001 From: Shipwreckt Date: Fri, 31 Oct 2025 20:02:14 +0000 Subject: Changed from static to 11ty! --- node_modules/@11ty/eleventy-dev-server/README.md | 60 ++ node_modules/@11ty/eleventy-dev-server/cli.js | 89 ++ .../eleventy-dev-server/client/reload-client.js | 336 +++++++ node_modules/@11ty/eleventy-dev-server/cmd.js | 77 ++ .../@11ty/eleventy-dev-server/package.json | 57 ++ node_modules/@11ty/eleventy-dev-server/server.js | 1024 ++++++++++++++++++++ .../@11ty/eleventy-dev-server/server/ipAddress.js | 9 + .../eleventy-dev-server/server/wrapResponse.js | 130 +++ 8 files changed, 1782 insertions(+) create mode 100644 node_modules/@11ty/eleventy-dev-server/README.md create mode 100644 node_modules/@11ty/eleventy-dev-server/cli.js create mode 100644 node_modules/@11ty/eleventy-dev-server/client/reload-client.js create mode 100755 node_modules/@11ty/eleventy-dev-server/cmd.js create mode 100644 node_modules/@11ty/eleventy-dev-server/package.json create mode 100644 node_modules/@11ty/eleventy-dev-server/server.js create mode 100644 node_modules/@11ty/eleventy-dev-server/server/ipAddress.js create mode 100644 node_modules/@11ty/eleventy-dev-server/server/wrapResponse.js (limited to 'node_modules/@11ty/eleventy-dev-server') diff --git a/node_modules/@11ty/eleventy-dev-server/README.md b/node_modules/@11ty/eleventy-dev-server/README.md new file mode 100644 index 0000000..2d74590 --- /dev/null +++ b/node_modules/@11ty/eleventy-dev-server/README.md @@ -0,0 +1,60 @@ +

11ty Logo

+ +# eleventy-dev-server 🕚⚡️🎈🐀 + +A minimal, modern, generic, hot-reloading local web server to help web developers. + +## ➡ [Documentation](https://www.11ty.dev/docs/watch-serve/#eleventy-dev-server) + +- Please star [Eleventy on GitHub](https://github.com/11ty/eleventy/)! +- Follow us on Twitter [@eleven_ty](https://twitter.com/eleven_ty) +- Support [11ty on Open Collective](https://opencollective.com/11ty) +- [11ty on npm](https://www.npmjs.com/org/11ty) +- [11ty on GitHub](https://github.com/11ty) + +[![npm Version](https://img.shields.io/npm/v/@11ty/eleventy-dev-server.svg?style=for-the-badge)](https://www.npmjs.com/package/@11ty/eleventy-dev-server) + +## Installation + +This is bundled with `@11ty/eleventy` (and you do not need to install it separately) in Eleventy v2.0. + +## CLI + +Eleventy Dev Server now also includes a CLI. The CLI is for **standalone** (non-Eleventy) use only: separate installation is unnecessary if you’re using this server with `@11ty/eleventy`. + +```sh +npm install -g @11ty/eleventy-dev-server + +# Alternatively, install locally into your project +npm install @11ty/eleventy-dev-server +``` + +This package requires Node 18 or newer. + +### CLI Usage + +```sh +# Serve the current directory +npx @11ty/eleventy-dev-server + +# Serve a different subdirectory (also aliased as --input) +npx @11ty/eleventy-dev-server --dir=_site + +# Disable the `domdiff` feature +npx @11ty/eleventy-dev-server --domdiff=false + +# Full command list in the Help +npx @11ty/eleventy-dev-server --help +``` + +## Tests + +``` +npm run test +``` + +- We use the [ava JavaScript test runner](https://github.com/avajs/ava) ([Assertions documentation](https://github.com/avajs/ava/blob/master/docs/03-assertions.md)) + +## Changelog + +* `v2.0.0` bumps Node.js minimum to 18. \ No newline at end of file diff --git a/node_modules/@11ty/eleventy-dev-server/cli.js b/node_modules/@11ty/eleventy-dev-server/cli.js new file mode 100644 index 0000000..04be1ca --- /dev/null +++ b/node_modules/@11ty/eleventy-dev-server/cli.js @@ -0,0 +1,89 @@ +const pkg = require("./package.json"); +const EleventyDevServer = require("./server.js"); + +const Logger = { + info: function(...args) { + console.log( "[11ty/eleventy-dev-server]", ...args ); + }, + error: function(...args) { + console.error( "[11ty/eleventy-dev-server]", ...args ); + }, + fatal: function(...args) { + Logger.error(...args); + process.exitCode = 1; + } +}; + +Logger.log = Logger.info; + +class Cli { + static getVersion() { + return pkg.version; + } + + static getHelp() { + return `Usage: + + eleventy-dev-server + eleventy-dev-server --dir=_site + eleventy-dev-server --port=3000 + +Arguments: + + --version + + --dir=. + Directory to serve (default: \`.\`) + + --input (alias for --dir) + + --port=8080 + Run the web server on this port (default: \`8080\`) + Will autoincrement if already in use. + + --domdiff (enabled, default) + --domdiff=false (disabled) + Apply HTML changes without a full page reload. + + --help`; + } + + static getDefaultOptions() { + return { + port: "8080", + input: ".", + domDiff: true, + } + } + + async serve(options = {}) { + this.options = Object.assign(Cli.getDefaultOptions(), options); + + this.server = EleventyDevServer.getServer("eleventy-dev-server-cli", this.options.input, { + // TODO allow server configuration extensions + showVersion: true, + logger: Logger, + domDiff: this.options.domDiff, + + // CLI watches all files in the folder by default + // this is different from Eleventy usage! + watch: [ this.options.input ], + }); + + this.server.serve(this.options.port); + + // TODO? send any errors here to the server too + // with server.sendError({ error }); + } + + close() { + if(this.server) { + return this.server.close(); + } + } +} + +module.exports = { + Logger, + Cli +} diff --git a/node_modules/@11ty/eleventy-dev-server/client/reload-client.js b/node_modules/@11ty/eleventy-dev-server/client/reload-client.js new file mode 100644 index 0000000..fc0ddd1 --- /dev/null +++ b/node_modules/@11ty/eleventy-dev-server/client/reload-client.js @@ -0,0 +1,336 @@ +class Util { + static pad(num, digits = 2) { + let zeroes = new Array(digits + 1).join(0); + return `${zeroes}${num}`.slice(-1 * digits); + } + + static log(message) { + Util.output("log", message); + } + static error(message, error) { + Util.output("error", message, error); + } + static output(type, ...messages) { + let now = new Date(); + let date = `${Util.pad(now.getUTCHours())}:${Util.pad( + now.getUTCMinutes() + )}:${Util.pad(now.getUTCSeconds())}.${Util.pad( + now.getUTCMilliseconds(), + 3 + )}`; + console[type](`[11ty][${date} UTC]`, ...messages); + } + + static capitalize(word) { + return word.substr(0, 1).toUpperCase() + word.substr(1); + } + + static matchRootAttributes(htmlContent) { + // Workaround for morphdom bug with attributes on https://github.com/11ty/eleventy-dev-server/issues/6 + // Note also `childrenOnly: true` above + const parser = new DOMParser(); + let parsed = parser.parseFromString(htmlContent, "text/html"); + let parsedDoc = parsed.documentElement; + let newAttrs = parsedDoc.getAttributeNames(); + + let docEl = document.documentElement; + // Remove old + let removedAttrs = docEl.getAttributeNames().filter(name => !newAttrs.includes(name)); + for(let attr of removedAttrs) { + docEl.removeAttribute(attr); + } + + // Add new + for(let attr of newAttrs) { + docEl.setAttribute(attr, parsedDoc.getAttribute(attr)); + } + } + + static isEleventyLinkNodeMatch(from, to) { + // Issue #18 https://github.com/11ty/eleventy-dev-server/issues/18 + // Don’t update a if the _11ty searchParam is the only thing that’s different + if(from.tagName !== "LINK" || to.tagName !== "LINK") { + return false; + } + + let oldWithoutHref = from.cloneNode(); + let newWithoutHref = to.cloneNode(); + + oldWithoutHref.removeAttribute("href"); + newWithoutHref.removeAttribute("href"); + + // if all other attributes besides href match + if(!oldWithoutHref.isEqualNode(newWithoutHref)) { + return false; + } + + let oldUrl = new URL(from.href); + let newUrl = new URL(to.href); + + // morphdom wants to force href="style.css?_11ty" => href="style.css" + let paramName = EleventyReload.QUERY_PARAM; + let isErasing = oldUrl.searchParams.has(paramName) && !newUrl.searchParams.has(paramName); + if(!isErasing) { + // not a match if _11ty has a new value (not being erased) + return false; + } + + oldUrl.searchParams.set(paramName, ""); + newUrl.searchParams.set(paramName, ""); + + // is a match if erasing and the rest of the href matches too + return oldUrl.toString() === newUrl.toString(); + } + + // https://github.com/patrick-steele-idem/morphdom/issues/178#issuecomment-652562769 + static runScript(source, target) { + let script = document.createElement('script'); + + // copy over the attributes + for(let attr of [...source.attributes]) { + script.setAttribute(attr.nodeName ,attr.nodeValue); + } + + script.innerHTML = source.innerHTML; + (target || source).replaceWith(script); + } + + static fullPageReload() { + Util.log(`Page reload initiated.`); + window.location.reload(); + } +} + +class EleventyReload { + static QUERY_PARAM = "_11ty"; + + static reloadTypes = { + css: (files, build = {}) => { + // Initiate a full page refresh if a CSS change is made but does match any stylesheet url + // `build.stylesheets` available in Eleventy v3.0.1-alpha.5+ + if(Array.isArray(build.stylesheets)) { + let match = false; + for (let link of document.querySelectorAll(`link[rel="stylesheet"]`)) { + if (link.href) { + let url = new URL(link.href); + if(build.stylesheets.includes(url.pathname)) { + match = true; + } + } + } + + if(!match) { + Util.fullPageReload(); + return; + } + } + + for (let link of document.querySelectorAll(`link[rel="stylesheet"]`)) { + if (link.href) { + let url = new URL(link.href); + url.searchParams.set(this.QUERY_PARAM, Date.now()); + link.href = url.toString(); + } + } + + Util.log(`CSS updated without page reload.`); + }, + default: async (files, build = {}) => { + let morphed = false; + let domdiffTemplates = (build?.templates || []).filter(({url, inputPath}) => { + return url === document.location.pathname && (files || []).includes(inputPath); + }); + + if(domdiffTemplates.length === 0) { + Util.fullPageReload(); + return; + } + + try { + // Important: using `./` allows the `.11ty` folder name to be changed + const { default: morphdom } = await import(`./morphdom.js`); + + for (let {url, inputPath, content} of domdiffTemplates) { + // Notable limitation: this won’t re-run script elements or JavaScript page lifecycle events (load/DOMContentLoaded) + morphed = true; + + morphdom(document.documentElement, content, { + childrenOnly: true, + onBeforeElUpdated: function (fromEl, toEl) { + if (fromEl.nodeName === "SCRIPT" && toEl.nodeName === "SCRIPT") { + if(toEl.innerHTML !== fromEl.innerHTML) { + Util.log(`JavaScript modified, reload initiated.`); + window.location.reload(); + } + + return false; + } + + // Speed-up trick from morphdom docs + // https://dom.spec.whatwg.org/#concept-node-equals + if (fromEl.isEqualNode(toEl)) { + return false; + } + + if(Util.isEleventyLinkNodeMatch(fromEl, toEl)) { + return false; + } + + return true; + }, + addChild: function(parent, child) { + // Declarative Shadow DOM https://github.com/11ty/eleventy-dev-server/issues/90 + if(child.nodeName === "TEMPLATE" && child.hasAttribute("shadowrootmode")) { + let root = parent.shadowRoot; + if(root) { + // remove all shadow root children + while(root.firstChild) { + root.removeChild(root.firstChild); + } + } + for(let newChild of child.content.childNodes) { + root.appendChild(newChild); + } + } else { + parent.appendChild(child); + } + }, + onNodeAdded: function (node) { + if (node.nodeName === 'SCRIPT') { + Util.log(`JavaScript added, reload initiated.`); + window.location.reload(); + } + }, + onElUpdated: function(node) { + // Re-attach custom elements + if(customElements.get(node.tagName.toLowerCase())) { + let placeholder = document.createElement("div"); + node.replaceWith(placeholder); + requestAnimationFrame(() => { + placeholder.replaceWith(node); + placeholder = undefined; + }); + } + } + }); + + Util.matchRootAttributes(content); + Util.log(`HTML delta applied without page reload.`); + } + } catch(e) { + Util.error( "Morphdom error", e ); + } + + if (!morphed) { + Util.fullPageReload(); + } + } + } + + constructor() { + this.connectionMessageShown = false; + this.reconnectEventCallback = this.reconnect.bind(this); + } + + init(options = {}) { + if (!("WebSocket" in window)) { + return; + } + + let documentUrl = new URL(document.location.href); + + let reloadPort = new URL(import.meta.url).searchParams.get("reloadPort"); + if(reloadPort) { + documentUrl.port = reloadPort; + } + + let { protocol, host } = documentUrl; + + // works with http (ws) and https (wss) + let websocketProtocol = protocol.replace("http", "ws"); + + let socket = new WebSocket(`${websocketProtocol}//${host}`); + + socket.addEventListener("message", async (event) => { + try { + let data = JSON.parse(event.data); + // Util.log( JSON.stringify(data, null, 2) ); + + let { type } = data; + + if (type === "eleventy.reload") { + await this.onreload(data); + } else if (type === "eleventy.msg") { + Util.log(`${data.message}`); + } else if (type === "eleventy.error") { + // Log Eleventy build errors + // Extra parsing for Node Error objects + let e = JSON.parse(data.error); + Util.error(`Build error: ${e.message}`, e); + } else if (type === "eleventy.status") { + // Full page reload on initial reconnect + if (data.status === "connected" && options.mode === "reconnect") { + window.location.reload(); + } + + if(data.status === "connected") { + // With multiple windows, only show one connection message + if(!this.isConnected) { + Util.log(Util.capitalize(data.status)); + } + + this.connectionMessageShown = true; + } else { + if(data.status === "disconnected") { + this.addReconnectListeners(); + } + + Util.log(Util.capitalize(data.status)); + } + } else { + Util.log("Unknown event type", data); + } + } catch (e) { + Util.error(`Error parsing ${event.data}: ${e.message}`, e); + } + }); + + socket.addEventListener("open", () => { + // no reconnection when the connect is already open + this.removeReconnectListeners(); + }); + + socket.addEventListener("close", () => { + this.connectionMessageShown = false; + this.addReconnectListeners(); + }); + } + + reconnect() { + Util.log( "Reconnecting…" ); + this.init({ mode: "reconnect" }); + } + + async onreload({ subtype, files, build }) { + if(!EleventyReload.reloadTypes[subtype]) { + subtype = "default"; + } + + await EleventyReload.reloadTypes[subtype](files, build); + } + + addReconnectListeners() { + this.removeReconnectListeners(); + + window.addEventListener("focus", this.reconnectEventCallback); + window.addEventListener("visibilitychange", this.reconnectEventCallback); + } + + removeReconnectListeners() { + window.removeEventListener("focus", this.reconnectEventCallback); + window.removeEventListener("visibilitychange", this.reconnectEventCallback); + } +} + +let reloader = new EleventyReload(); +reloader.init(); \ No newline at end of file diff --git a/node_modules/@11ty/eleventy-dev-server/cmd.js b/node_modules/@11ty/eleventy-dev-server/cmd.js new file mode 100755 index 0000000..695b845 --- /dev/null +++ b/node_modules/@11ty/eleventy-dev-server/cmd.js @@ -0,0 +1,77 @@ +#!/usr/bin/env node + +const pkg = require("./package.json"); + +// Node check +require("please-upgrade-node")(pkg, { + message: function (requiredVersion) { + return ( + "eleventy-dev-server requires Node " + + requiredVersion + + ". You will need to upgrade Node!" + ); + }, +}); + +const { Logger, Cli } = require("./cli.js"); + +const debug = require("debug")("Eleventy:DevServer"); + +try { + const defaults = Cli.getDefaultOptions(); + for(let key in defaults) { + if(key.toLowerCase() !== key) { + defaults[key.toLowerCase()] = defaults[key]; + delete defaults[key]; + } + } + + const argv = require("minimist")(process.argv.slice(2), { + string: [ + "dir", + "input", // alias for dir + "port", + ], + boolean: [ + "version", + "help", + "domdiff", + ], + default: defaults, + unknown: function (unknownArgument) { + throw new Error( + `We don’t know what '${unknownArgument}' is. Use --help to see the list of supported commands.` + ); + }, + }); + + debug("command: eleventy-dev-server %o", argv); + + process.on("unhandledRejection", (error, promise) => { + Logger.fatal("Unhandled rejection in promise:", promise, error); + }); + process.on("uncaughtException", (error) => { + Logger.fatal("Uncaught exception:", error); + }); + + if (argv.version) { + console.log(Cli.getVersion()); + } else if (argv.help) { + console.log(Cli.getHelp()); + } else { + let cli = new Cli(); + + cli.serve({ + input: argv.dir || argv.input, + port: argv.port, + domDiff: argv.domdiff, + }); + + process.on("SIGINT", async () => { + await cli.close(); + process.exitCode = 0; + }); + } +} catch (e) { + Logger.fatal("Fatal Error:", e) +} diff --git a/node_modules/@11ty/eleventy-dev-server/package.json b/node_modules/@11ty/eleventy-dev-server/package.json new file mode 100644 index 0000000..e413c7c --- /dev/null +++ b/node_modules/@11ty/eleventy-dev-server/package.json @@ -0,0 +1,57 @@ +{ + "name": "@11ty/eleventy-dev-server", + "version": "2.0.8", + "description": "A minimal, modern, generic, hot-reloading local web server to help web developers.", + "main": "server.js", + "scripts": { + "test": "npx ava --verbose", + "sample": "node cmd.js --input=test/stubs" + }, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/11ty" + }, + "bin": { + "eleventy-dev-server": "./cmd.js" + }, + "keywords": [ + "eleventy", + "server", + "cli" + ], + "publishConfig": { + "access": "public" + }, + "author": { + "name": "Zach Leatherman", + "email": "zachleatherman@gmail.com", + "url": "https://zachleat.com/" + }, + "repository": { + "type": "git", + "url": "git://github.com/11ty/eleventy-dev-server.git" + }, + "bugs": "https://github.com/11ty/eleventy-dev-server/issues", + "homepage": "https://github.com/11ty/eleventy-dev-server/", + "dependencies": { + "@11ty/eleventy-utils": "^2.0.1", + "chokidar": "^3.6.0", + "debug": "^4.4.0", + "finalhandler": "^1.3.1", + "mime": "^3.0.0", + "minimist": "^1.2.8", + "morphdom": "^2.7.4", + "please-upgrade-node": "^3.2.0", + "send": "^1.1.0", + "ssri": "^11.0.0", + "urlpattern-polyfill": "^10.0.0", + "ws": "^8.18.1" + }, + "devDependencies": { + "ava": "^6.2.0" + } +} diff --git a/node_modules/@11ty/eleventy-dev-server/server.js b/node_modules/@11ty/eleventy-dev-server/server.js new file mode 100644 index 0000000..9fbe898 --- /dev/null +++ b/node_modules/@11ty/eleventy-dev-server/server.js @@ -0,0 +1,1024 @@ +const path = require("node:path"); +const fs = require("node:fs"); + +const finalhandler = require("finalhandler"); +const WebSocket = require("ws"); +const { WebSocketServer } = WebSocket; +const mime = require("mime"); +const ssri = require("ssri"); +const send = require("send"); +const chokidar = require("chokidar"); +const { TemplatePath, isPlainObject } = require("@11ty/eleventy-utils"); + +const debug = require("debug")("Eleventy:DevServer"); + +const pkg = require("./package.json"); +const wrapResponse = require("./server/wrapResponse.js"); +const ipAddress = require("./server/ipAddress.js"); + +if (!globalThis.URLPattern) { + require("urlpattern-polyfill"); +} + +const DEFAULT_OPTIONS = { + port: 8080, + reloadPort: false, // Falsy uses same as `port` + liveReload: true, // Enable live reload at all + showAllHosts: false, // IP address based hosts (other than localhost) + injectedScriptsFolder: ".11ty", // Change the name of the special folder used for injected scripts + portReassignmentRetryCount: 10, // number of times to increment the port if in use + https: {}, // `key` and `cert`, required for http/2 and https + domDiff: true, // Use morphdom to apply DOM diffing delta updates to HTML + showVersion: false, // Whether or not to show the server version on the command line. + encoding: "utf-8", // Default file encoding + pathPrefix: "/", // May be overridden by Eleventy, adds a virtual base directory to your project + watch: [], // Globs to pass to separate dev server chokidar for watching + aliases: {}, // Aliasing feature + indexFileName: "index.html", // Allow custom index file name + useCache: false, // Use a cache for file contents + headers: {}, // Set default response headers + messageOnStart: ({ hosts, startupTime, version, options }) => { + let hostsStr = " started"; + if(Array.isArray(hosts) && hosts.length > 0) { + // TODO what happens when the cert doesn’t cover non-localhost hosts? + hostsStr = ` at ${hosts.join(" or ")}`; + } + + return `Server${hostsStr}${options.showVersion ? ` (v${version})` : ""}`; + }, + + onRequest: {}, // Maps URLPatterns to dynamic callback functions that run on a request from a client. + + // Example: + // "/foo/:name": function({ url, pattern, patternGroups }) { + // return { + // headers: { + // "Content-Type": "text/html", + // }, + // body: `${url} ${JSON.stringify(patternGroups)}` + // } + // } + + // Logger (fancier one is injected by Eleventy) + logger: { + info: console.log, + log: console.log, + error: console.error, + } +} + +class EleventyDevServer { + #watcher; + #serverClosing; + #serverState; + #readyPromise; + #readyResolve; + + static getServer(...args) { + return new EleventyDevServer(...args); + } + + constructor(name, dir, options = {}) { + debug("Creating new Dev Server instance.") + this.name = name; + this.normalizeOptions(options); + + this.fileCache = {}; + // Directory to serve + if(!dir) { + throw new Error("Missing `dir` to serve."); + } + this.dir = dir; + this.logger = this.options.logger; + this.getWatcher(); + + this.#readyPromise = new Promise((resolve) => { + this.#readyResolve = resolve; + }) + } + + normalizeOptions(options = {}) { + this.options = Object.assign({}, DEFAULT_OPTIONS, options); + + // better names for options https://github.com/11ty/eleventy-dev-server/issues/41 + if(options.folder !== undefined) { + this.options.injectedScriptsFolder = options.folder; + delete this.options.folder; + } + if(options.domdiff !== undefined) { + this.options.domDiff = options.domdiff; + delete this.options.domdiff; + } + if(options.enabled !== undefined) { + this.options.liveReload = options.enabled; + delete this.options.enabled; + } + + this.options.pathPrefix = this.cleanupPathPrefix(this.options.pathPrefix); + } + + get watcher() { + if(this.#watcher) { + return this.#watcher; + } + + debug("Watching %O", this.options.watch); + // TODO if using Eleventy and `watch` option includes output folder (_site) this will trigger two update events! + this.#watcher = chokidar.watch(this.options.watch, { + // TODO allow chokidar configuration extensions (or re-use the ones in Eleventy) + + ignored: ["**/node_modules/**", ".git"], + ignoreInitial: true, + + // same values as Eleventy + awaitWriteFinish: { + stabilityThreshold: 150, + pollInterval: 25, + }, + }); + + this.#watcher.on("change", (path) => { + this.logger.log( `File changed: ${path} (skips build)` ); + this.reloadFiles([path]); + }); + + this.#watcher.on("add", (path) => { + this.logger.log( `File added: ${path} (skips build)` ); + this.reloadFiles([path]); + }); + + return this.#watcher; + } + + getWatcher() { + // only initialize watcher if watcher via getWatcher if has targets + // this.watcher in watchFiles() is a manual workaround + if(this.options.watch.length > 0) { + return this.watcher; + } + } + + watchFiles(files) { + if(Array.isArray(files) && files.length > 0) { + files = files.map(entry => TemplatePath.stripLeadingDotSlash(entry)); + + debug("Also watching %O", files); + this.watcher.add(files); + } + } + + cleanupPathPrefix(pathPrefix) { + if(!pathPrefix || pathPrefix === "/") { + return "/"; + } + if(!pathPrefix.startsWith("/")) { + pathPrefix = `/${pathPrefix}` + } + if(!pathPrefix.endsWith("/")) { + pathPrefix = `${pathPrefix}/`; + } + return pathPrefix; + } + + // Allowed list of files that can be served from outside `dir` + setAliases(aliases) { + if(aliases) { + this.passthroughAliases = aliases; + debug( "Setting aliases (emulated passthrough copy) %O", aliases ); + } + } + + matchPassthroughAlias(url) { + let aliases = Object.assign({}, this.options.aliases, this.passthroughAliases); + for(let targetUrl in aliases) { + if(!targetUrl) { + continue; + } + + let file = aliases[targetUrl]; + if(url.startsWith(targetUrl)) { + let inputDirectoryPath = file + url.slice(targetUrl.length); + + // e.g. addPassthroughCopy("img/") but + // generated by the image plugin (written to the output folder) + // If they do not exist in the input directory, this will fallback to the output directory. + if(fs.existsSync(inputDirectoryPath)) { + return inputDirectoryPath; + } + } + } + return false; + } + + isFileInDirectory(dir, file) { + let absoluteDir = TemplatePath.absolutePath(dir); + let absoluteFile = TemplatePath.absolutePath(file); + return absoluteFile.startsWith(absoluteDir); + } + + getOutputDirFilePath(filepath, filename = "") { + let computedPath; + if(filename === ".html") { + // avoid trailing slash for filepath/.html requests + let prefix = path.join(this.dir, filepath); + if(prefix.endsWith(path.sep)) { + prefix = prefix.substring(0, prefix.length - path.sep.length); + } + computedPath = prefix + filename; + } else { + computedPath = path.join(this.dir, filepath, filename); + } + + computedPath = decodeURIComponent(computedPath); + + if(!filename) { // is a direct URL request (not an implicit .html or index.html add) + let alias = this.matchPassthroughAlias(filepath); + + if(alias) { + if(!this.isFileInDirectory(path.resolve("."), alias)) { + throw new Error("Invalid path"); + } + + return alias; + } + } + + // Check that the file is in the output path (error if folks try use `..` in the filepath) + if(!this.isFileInDirectory(this.dir, computedPath)) { + throw new Error("Invalid path"); + } + + return computedPath; + } + + isOutputFilePathExists(rawPath) { + return fs.existsSync(rawPath) && !TemplatePath.isDirectorySync(rawPath); + } + + /* Use conventions documented here https://www.zachleat.com/web/trailing-slash/ + * resource.html exists: + * /resource matches + * /resource/ redirects to /resource + * resource/index.html exists: + * /resource redirects to /resource/ + * /resource/ matches + * both resource.html and resource/index.html exists: + * /resource matches /resource.html + * /resource/ matches /resource/index.html + */ + mapUrlToFilePath(url) { + // Note: `localhost` is not important here, any host would work + let u = new URL(url, "http://localhost/"); + url = u.pathname; + + // Remove PathPrefix from start of URL + if (this.options.pathPrefix !== "/") { + // Requests to root should redirect to new pathPrefix + if(url === "/") { + return { + statusCode: 302, + url: this.options.pathPrefix, + } + } + + // Requests to anything outside of root should fail with 404 + if (!url.startsWith(this.options.pathPrefix)) { + return { + statusCode: 404, + }; + } + + url = url.slice(this.options.pathPrefix.length - 1); + } + + let rawPath = this.getOutputDirFilePath(url); + if (this.isOutputFilePathExists(rawPath)) { + return { + statusCode: 200, + filepath: rawPath, + }; + } + + let indexHtmlPath = this.getOutputDirFilePath(url, this.options.indexFileName); + let indexHtmlExists = fs.existsSync(indexHtmlPath); + + let htmlPath = this.getOutputDirFilePath(url, ".html"); + let htmlExists = fs.existsSync(htmlPath); + + // /resource/ => /resource/index.html + if (indexHtmlExists && url.endsWith("/")) { + return { + statusCode: 200, + filepath: indexHtmlPath, + }; + } + // /resource => resource.html + if (htmlExists && !url.endsWith("/")) { + return { + statusCode: 200, + filepath: htmlPath, + }; + } + + // /resource => redirect to /resource/ + if (indexHtmlExists && !url.endsWith("/")) { + return { + statusCode: 301, + url: u.pathname + "/", + }; + } + + // /resource/ => redirect to /resource + if (htmlExists && url.endsWith("/")) { + return { + statusCode: 301, + url: u.pathname.substring(0, u.pathname.length - 1), + }; + } + + return { + statusCode: 404, + }; + } + + #readFile(filepath) { + if(this.options.useCache && this.fileCache[filepath]) { + return this.fileCache[filepath]; + } + + let contents = fs.readFileSync(filepath, { + encoding: this.options.encoding, + }); + + if(this.options.useCache) { + this.fileCache[filepath] = contents; + } + + return contents; + } + + #getFileContents(localpath, rootDir) { + let filepath; + let searchLocations = []; + + if(rootDir) { + searchLocations.push(TemplatePath.absolutePath(rootDir, localpath)); + } + + // fallbacks for file:../ installations + searchLocations.push(TemplatePath.absolutePath(__dirname, localpath)); + searchLocations.push(TemplatePath.absolutePath(__dirname, "../../../", localpath)); + + for(let loc of searchLocations) { + if(fs.existsSync(loc)) { + filepath = loc; + break; + } + } + + return this.#readFile(filepath); + } + + augmentContentWithNotifier(content, inlineContents = false, options = {}) { + let { integrityHash, scriptContents } = options; + if(!scriptContents) { + scriptContents = this.#getFileContents("./client/reload-client.js"); + } + if(!integrityHash) { + integrityHash = ssri.fromData(scriptContents); + } + + let searchParams = new URLSearchParams(); + if(this.options.reloadPort) { + searchParams.set("reloadPort", this.options.reloadPort); + } + + let searchParamsStr = searchParams.size > 0 ? `?${searchParams.toString()}` : ""; + + // This isn’t super necessary because it’s a local file, but it’s included anyway + let script = ``; + + if (content.includes("")) { + return content.replace("", `${script}`); + } + + // If the HTML document contains an importmap, insert the module script after the importmap element + let importMapRegEx = /