Repository: 11ty/eleventy
Pull request #1853 (2021-06-24 03:17:40)

Updates to serverless for collections
Warning, this removes the `pathnameOverridesPageUrl` bundler option.


Patch:
diff --git a/src/Filters/ServerlessUrl.js b/src/Filters/ServerlessUrl.js
new file mode 100644
index 00000000..a4deb42e
--- /dev/null
+++ b/src/Filters/ServerlessUrl.js
@@ -0,0 +1,14 @@
+const UrlPattern = require("url-pattern");
+
+function stringify(url, urlData = {}) {
+  let pattern = new UrlPattern(url);
+  return pattern.stringify(urlData);
+}
+
+module.exports = function (url, urlData = {}) {
+  if (Array.isArray(url)) {
+    return url.slice().map((entry) => stringify(entry, urlData));
+  }
+
+  return stringify(url, urlData);
+};
diff --git a/src/Plugins/DefaultServerlessFunctionContent.js b/src/Plugins/DefaultServerlessFunctionContent.js
new file mode 100644
index 00000000..47945cd4
--- /dev/null
+++ b/src/Plugins/DefaultServerlessFunctionContent.js
@@ -0,0 +1,51 @@
+const { EleventyServerless } = require("@11ty/eleventy");
+
+// Explicit dependencies for the bundler from config file and global data.
+// The file is generated by the Eleventy Serverless Bundler Plugin.
+require("./eleventy-bundler-modules.js");
+
+async function handler(event) {
+  let elev = new EleventyServerless("%%NAME%%", {
+    path: event.path,
+    query: event.queryStringParameters,
+    inputDir: "%%INPUT_DIR%%",
+    functionsDir: "%%FUNCTIONS_DIR%%",
+  });
+
+  try {
+    return {
+      statusCode: 200,
+      headers: {
+        "Content-Type": "text/html; charset=UTF-8",
+      },
+      body: await elev.render(),
+    };
+  } catch (error) {
+    // Only console log for matching serverless paths
+    // (otherwise you’ll see a bunch of BrowserSync 404s for non-dynamic URLs during --serve)
+    if (elev.isServerlessUrl(event.path)) {
+      console.log("Serverless Error:", error);
+    }
+
+    return {
+      statusCode: error.httpStatusCode || 500,
+      body: JSON.stringify(
+        {
+          error: error.message,
+        },
+        null,
+        2
+      ),
+    };
+  }
+}
+
+// Choose one:
+// * Runs on each request: AWS Lambda (or Netlify Function)
+// * Runs on first request only: Netlify On-demand Builder
+//   (don’t forget to `npm install @netlify/functions`)
+
+exports.handler = handler;
+
+//const { builder } = require("@netlify/functions");
+//exports.handler = builder(handler);
diff --git a/src/Plugins/ServerlessBundlerPlugin.js b/src/Plugins/ServerlessBundlerPlugin.js
index f938781c..183fa120 100644
--- a/src/Plugins/ServerlessBundlerPlugin.js
+++ b/src/Plugins/ServerlessBundlerPlugin.js
@@ -1,4 +1,5 @@
 const fs = require("fs");
+const fsp = fs.promises;
 const path = require("path");
 const TOML = require("@iarna/toml");
 const copy = require("recursive-copy");
@@ -156,13 +157,23 @@ class BundlerHelper {
   }
 
   writeDependencyEntryFile() {
-    this.writeBundlerDependenciesFile("eleventy-bundler-modules.js", [
-      "./eleventy-app-config-modules.js",
-      "./eleventy-app-globaldata-modules.js",
-    ]);
+    // we write this even when disabled because the serverless function expects it
+    this.writeBundlerDependenciesFile(
+      "eleventy-bundler-modules.js",
+      this.options.copyEnabled
+        ? [
+            "./eleventy-app-config-modules.js",
+            "./eleventy-app-globaldata-modules.js",
+          ]
+        : []
+    );
   }
 
   writeDependencyConfigFile(configPath) {
+    if (!this.options.copyEnabled) {
+      return;
+    }
+
     let modules = getNodeModulesList([configPath]);
     this.writeBundlerDependenciesFile(
       "eleventy-app-config-modules.js",
@@ -173,6 +184,10 @@ class BundlerHelper {
   }
 
   writeDependencyGlobalDataFile(globalDataFileList) {
+    if (!this.options.copyEnabled) {
+      return;
+    }
+
     let modules = getNodeModulesList(globalDataFileList);
     this.writeBundlerDependenciesFile(
       "eleventy-app-globaldata-modules.js",
@@ -211,9 +226,36 @@ class BundlerHelper {
       res.write(result.body);
       res.end();
 
-      console.log(`Dynamic Render: ${req.url} (${Date.now() - start}ms)`);
+      console.log(
+        `Serverless (${this.name}): ${req.url} (${Date.now() - start}ms)`
+      );
     };
   }
+
+  async ensureDir() {
+    return fsp.mkdir(this.getOutputPath(""), {
+      recursive: true,
+    });
+  }
+
+  async writeServerlessFunctionFile() {
+    let filepath = this.getOutputPath("index.js");
+    if (!fs.existsSync(filepath)) {
+      let defaultContentPath = TemplatePath.absolutePath(
+        __dirname,
+        "./DefaultServerlessFunctionContent.js"
+      );
+
+      let contents = await fsp.readFile(defaultContentPath, "utf-8");
+      contents = contents.replace(/\%\%NAME\%\%/g, this.name);
+      contents = contents.replace(/\%\%INPUT_DIR\%\%/g, this.options.inputDir);
+      contents = contents.replace(
+        /\%\%FUNCTIONS_DIR\%\%/g,
+        this.options.functionsDir
+      );
+      return fsp.writeFile(filepath, contents);
+    }
+  }
 }
 
 function EleventyPlugin(eleventyConfig, options = {}) {
@@ -233,6 +275,12 @@ function EleventyPlugin(eleventyConfig, options = {}) {
       // Add automated redirects (appends or creates, avoids duplicate entries)
       // Also accepts a custom callback function(name, outputMap)
       redirects: "netlify-toml",
+
+      // Useful for local develop to disable all bundle copying
+      copyEnabled: true,
+
+      // Input directory (used to generate the default serverless file)
+      inputDir: ".",
     },
     options
   );
@@ -252,10 +300,16 @@ function EleventyPlugin(eleventyConfig, options = {}) {
 
     eleventyConfig.on("eleventy.before", async () => {
       helper.reset();
+      await helper.ensureDir();
+      await helper.writeServerlessFunctionFile();
       helper.writeDependencyEntryFile();
     });
 
     eleventyConfig.on("eleventy.after", async () => {
+      if (!options.copyEnabled) {
+        return;
+      }
+
       // extra copy targets
       // we put these in after a build so that we can grab files generated _by_ the build too
       if (options.copy && Array.isArray(options.copy)) {
@@ -282,9 +336,14 @@ function EleventyPlugin(eleventyConfig, options = {}) {
       );
     });
 
-    eleventyConfig.on("eleventy.env", (env) => {
-      helper.copyFile(env.config, "eleventy.config.js");
-      helper.writeDependencyConfigFile(env.config);
+    eleventyConfig.on("eleventy.env", async (env) => {
+      await helper.ensureDir();
+
+      if (options.copyEnabled) {
+        helper.copyFile(env.config, "eleventy.config.js");
+
+        helper.writeDependencyConfigFile(env.config);
+      }
     });
 
     eleventyConfig.on("eleventy.globalDataFiles", (fileList) => {
@@ -292,6 +351,10 @@ function EleventyPlugin(eleventyConfig, options = {}) {
     });
 
     eleventyConfig.on("eleventy.directories", async (dirs) => {
+      if (!options.copyEnabled) {
+        return;
+      }
+
       let promises = [];
       promises.push(helper.recursiveCopy(dirs.data));
       promises.push(helper.recursiveCopy(dirs.includes));
@@ -341,7 +404,7 @@ function EleventyPlugin(eleventyConfig, options = {}) {
       this.copyCount++;
 
       // Write redirects (even if no redirects exist for this function to handle deletes)
-      if (options.redirects) {
+      if (options.copyEnabled && options.redirects) {
         if (
           typeof options.redirects === "string" &&
           redirectHandlers[options.redirects]
@@ -352,7 +415,7 @@ function EleventyPlugin(eleventyConfig, options = {}) {
         }
       }
 
-      if (mapEntryCount > 0) {
+      if (options.copyEnabled && mapEntryCount > 0) {
         // Copy templates to bundle folder
         for (let url in outputMap) {
           helper.recursiveCopy(outputMap[url]);
diff --git a/src/Serverless.js b/src/Serverless.js
index 47fa6b83..3e2847ce 100644
--- a/src/Serverless.js
+++ b/src/Serverless.js
@@ -9,7 +9,21 @@ const debug = require("debug")("Eleventy:Serverless");
 class Serverless {
   constructor(name, path, options = {}) {
     this.name = name;
-    this.path = path;
+
+    // second argument is path
+    if (typeof path === "string") {
+      this.path = path;
+    } else {
+      // options is the second argument and path is inside options
+      options = path;
+      this.path = options.path;
+    }
+
+    if (!this.path) {
+      throw new Error(
+        "`path` must exist in the options argument in Eleventy Serverless."
+      );
+    }
 
     // ServerlessBundlerPlugin hard-codes to this (even if you used a different file name)
     this.configFilename = "eleventy.config.js";
@@ -21,8 +35,6 @@ class Serverless {
       {
         inputDir: ".",
         functionsDir: "functions/",
-        // Whether the serverless pathname overrides the page.url data value.
-        pathnameOverridesPageUrl: true,
         matchUrlToPattern(path, urlToCompare) {
           let pattern = new UrlPattern(urlToCompare);
           return pattern.match(path);
@@ -113,7 +125,7 @@ class Serverless {
       process.chdir(this.dir);
     }
 
-    let inputDir = this.options.inputDir;
+    let inputDir = this.options.input || this.options.inputDir;
     let configPath = path.join(this.dir, this.configFilename);
     let { pathParams, inputPath } = this.matchUrlPattern(this.path);
 
@@ -136,7 +148,7 @@ class Serverless {
     debug("Path params: %o", pathParams);
     debug(`Input path:  ${inputPath}`);
 
-    let elev = new Eleventy(inputPath, null, {
+    let elev = new Eleventy(this.options.input || inputPath, null, {
       configPath,
       inputDir,
       config: (eleventyConfig) => {
@@ -148,17 +160,10 @@ class Serverless {
 
         // Add the params to Global Data
         let globalData = {
-          pathname: this.path,
           query: this.options.query,
           path: pathParams,
         };
 
-        // leave out if default value
-        if (!this.options.pathnameOverridesPageUrl) {
-          globalData.pathnameOverridesPageUrl =
-            this.options.pathnameOverridesPageUrl;
-        }
-
         eleventyConfig.addGlobalData("eleventy.serverless", globalData);
       },
     });
diff --git a/src/Template.js b/src/Template.js
index aaf9313d..c37d717f 100755
--- a/src/Template.js
+++ b/src/Template.js
@@ -195,30 +195,45 @@ class Template extends TemplateContent {
       debugDev("Not using dynamic permalinks, using %o", permalink);
       permalinkValue = permalink;
     } else if (isPlainObject(permalink)) {
-      let promises = [];
-      let keys = [];
-      for (let key in permalink) {
-        keys.push(key);
-        promises.push(super.render(permalink[key], data, true));
-      }
+      // Empty permalink {} object should act as if no permalink was set at all
+      // and inherit the default behavior
+      let isEmptyObject = Object.keys(permalink).length === 0;
+      if (!isEmptyObject) {
+        let promises = [];
+        let keys = [];
+        for (let key in permalink) {
+          keys.push(key);
+          if (key !== "build" && Array.isArray(permalink[key])) {
+            promises.push(
+              Promise.all(
+                [...permalink[key]].map((entry) =>
+                  super.renderPermalink(entry, data, true)
+                )
+              )
+            );
+          } else {
+            promises.push(super.renderPermalink(permalink[key], data, true));
+          }
+        }
 
-      let results = await Promise.all(promises);
+        let results = await Promise.all(promises);
 
-      permalinkValue = {};
-      for (let j = 0, k = keys.length; j < k; j++) {
-        let key = keys[j];
-        permalinkValue[key] = results[j];
-        debug(
-          "Rendering permalink.%o for %o: %s becomes %o",
-          key,
-          this.inputPath,
-          permalink[key],
-          results[j]
-        );
+        permalinkValue = {};
+        for (let j = 0, k = keys.length; j < k; j++) {
+          let key = keys[j];
+          permalinkValue[key] = results[j];
+          debug(
+            "Rendering permalink.%o for %o: %s becomes %o",
+            key,
+            this.inputPath,
+            permalink[key],
+            results[j]
+          );
+        }
       }
     } else if (permalink) {
       // render variables inside permalink front matter, bypass markdown
-      permalinkValue = await super.render(permalink, data, true);
+      permalinkValue = await super.renderPermalink(permalink, data, true);
       debug(
         "Rendering permalink for %o: %s becomes %o",
         this.inputPath,
@@ -242,7 +257,6 @@ class Template extends TemplateContent {
     );
   }
 
-  // TODO add support for a key inside the `permalink` object for this
   async usePermalinkRoot() {
     if (this._usePermalinkRoot === undefined) {
       // TODO this only works with immediate front matter and not data files
@@ -254,17 +268,6 @@ class Template extends TemplateContent {
     return this._usePermalinkRoot;
   }
 
-  _useServerlessOverride(data) {
-    return get(data, "eleventy.serverless.pathnameOverridesPageUrl", true);
-  }
-
-  _getServerlessPath(data) {
-    let pathname = get(data, "eleventy.serverless.pathname");
-    if (pathname) {
-      return pathname;
-    }
-  }
-
   // TODO instead of htmlIOException, do a global search to check if output path = input path and then add extra suffix
   async getOutputLocations(data) {
     let link = await this._getLink(data);
@@ -276,18 +279,6 @@ class Template extends TemplateContent {
       path = link.toPath(this.outputDir);
     }
 
-    // Makes page.url out of the serverless pathname
-    let serverlessPath = this._getServerlessPath(data);
-    if (serverlessPath) {
-      return {
-        link: false,
-        href: this._useServerlessOverride(data)
-          ? serverlessPath
-          : link.toHref(),
-        path: false,
-      };
-    }
-
     return {
       link: link.toLink(),
       href: link.toHref(),
@@ -297,9 +288,6 @@ class Template extends TemplateContent {
 
   // Preferred to use the singular `getOutputLocations` above.
   async getOutputLink(data) {
-    if (this._getServerlessPath(data)) {
-      return false;
-    }
     let link = await this._getLink(data);
     return link.toLink();
   }
@@ -307,17 +295,11 @@ class Template extends TemplateContent {
   // Preferred to use the singular `getOutputLocations` above.
   async getOutputHref(data) {
     let link = await this._getLink(data);
-    let pathname = this._getServerlessPath(data);
-    return pathname && this._useServerlessOverride(data)
-      ? pathname
-      : link.toHref();
+    return link.toHref();
   }
 
   // Preferred to use the singular `getOutputLocations` above.
   async getOutputPath(data) {
-    if (this._getServerlessPath(data)) {
-      return false;
-    }
     let link = await this._getLink(data);
     if (await this.usePermalinkRoot()) {
       return link.toPathFromRoot();
@@ -578,6 +560,7 @@ class Template extends TemplateContent {
     if (this.config.keys.computed in data) {
       // Note that `permalink` is only a thing that gets consumed—it does not go directly into generated data
       // this allows computed entries to use page.url or page.outputPath and they’ll be resolved properly
+
       this.computedData.addTemplateString(
         "page.url",
         async (data) => await this.getOutputHref(data),
diff --git a/src/TemplateContent.js b/src/TemplateContent.js
index 813e4375..c89388c5 100644
--- a/src/TemplateContent.js
+++ b/src/TemplateContent.js
@@ -282,7 +282,15 @@ class TemplateContent {
     }
   }
 
+  async renderPermalink(permalink, data, bypassMarkdown) {
+    return this._render(permalink, data, bypassMarkdown);
+  }
+
   async render(str, data, bypassMarkdown) {
+    return this._render(str, data, bypassMarkdown);
+  }
+
+  async _render(str, data, bypassMarkdown) {
     try {
       let fn = await this.compile(str, bypassMarkdown);
       let templateBenchmark = bench.get("Template Render");
diff --git a/src/TemplateData.js b/src/TemplateData.js
index 1af9a050..bf90503d 100755
--- a/src/TemplateData.js
+++ b/src/TemplateData.js
@@ -462,6 +462,9 @@ class TemplateData {
       deleteRequireCache(localPath);
 
       let returnValue = require(localPath);
+      // TODO special exception for Global data `permalink.js`
+      // module.exports = (data) => `${data.page.filePathStem}/`; // Does not work
+      // module.exports = () => ((data) => `${data.page.filePathStem}/`); // Works
       if (typeof returnValue === "function") {
         returnValue = await returnValue(this.configApiGlobalData || {});
       }
diff --git a/src/TemplatePermalink.js b/src/TemplatePermalink.js
index d70563a6..cb098be2 100644
--- a/src/TemplatePermalink.js
+++ b/src/TemplatePermalink.js
@@ -11,18 +11,35 @@ class TemplatePermalink {
     this._isRendered = true;
     this._writeToFileSystem = true;
 
-    let rawLink;
+    let buildLink;
+
     if (isLinkAnObject) {
       if ("build" in link) {
-        rawLink = link.build;
+        buildLink = link.build;
+      }
+
+      // find the first string key
+      for (let key in link) {
+        if (typeof key !== "string") {
+          continue;
+        }
+        if (key !== "build" && link[key] !== false) {
+          // is array of serverless urls, use the first one
+          if (Array.isArray(link[key])) {
+            this.primaryServerlessUrl = link[key][0];
+          } else {
+            this.primaryServerlessUrl = link[key];
+          }
+        }
+        break;
       }
     } else {
-      rawLink = link;
+      buildLink = link;
     }
 
     // permalink: false and permalink: build: false
-    if (typeof rawLink === "boolean") {
-      if (rawLink === false) {
+    if (typeof buildLink === "boolean") {
+      if (buildLink === false) {
         this._writeToFileSystem = false;
       } else {
         throw new Error(
@@ -33,8 +50,8 @@ class TemplatePermalink {
           }false\`?`
         );
       }
-    } else if (rawLink) {
-      this.rawLink = rawLink;
+    } else if (buildLink) {
+      this.buildLink = buildLink;
     }
 
     this.serverlessUrls = {};
@@ -57,17 +74,19 @@ class TemplatePermalink {
     return this.serverlessUrls;
   }
 
-  _cleanLink(link) {
+  _addDefaultLinkFilename(link) {
     return link + (link.substr(-1) === "/" ? "index.html" : "");
   }
 
   toLink() {
-    if (!this.rawLink) {
+    if (this.primaryServerlessUrl) {
+      return this.primaryServerlessUrl;
+    } else if (!this.buildLink) {
       // empty or false
       return false;
     }
 
-    let cleanLink = this._cleanLink(this.rawLink);
+    let cleanLink = this._addDefaultLinkFilename(this.buildLink);
     let parsed = parsePath(cleanLink);
 
     return TemplatePath.join(
@@ -84,7 +103,9 @@ class TemplatePermalink {
   // index.html becomes /
   // test/index.html becomes test/
   toHref() {
-    if (!this.rawLink) {
+    if (this.primaryServerlessUrl) {
+      return this.primaryServerlessUrl;
+    } else if (!this.buildLink) {
       // empty or false
       return false;
     }
@@ -102,6 +123,10 @@ class TemplatePermalink {
   }
 
   toPath(outputDir) {
+    if (!this.buildLink) {
+      return false;
+    }
+
     let uri = this.toLink();
 
     if (uri === false) {
@@ -112,6 +137,10 @@ class TemplatePermalink {
   }
 
   toPathFromRoot() {
+    if (!this.buildLink) {
+      return false;
+    }
+
     let uri = this.toLink();
 
     if (uri === false) {
diff --git a/src/defaultConfig.js b/src/defaultConfig.js
index 20538b6b..474c124e 100644
--- a/src/defaultConfig.js
+++ b/src/defaultConfig.js
@@ -1,4 +1,5 @@
 const urlFilter = require("./Filters/Url");
+const serverlessUrlFilter = require("./Filters/ServerlessUrl");
 const slugFilter = require("./Filters/Slug");
 const getCollectionItem = require("./Filters/GetCollectionItem");
 
@@ -12,6 +13,8 @@ module.exports = function (config) {
   });
   config.addFilter("log", console.log);
 
+  config.addFilter("serverlessUrl", serverlessUrlFilter);
+
   config.addFilter("getCollectionItem", (collection, page) =>
     getCollectionItem(collection, page)
   );


Test patch:
diff --git a/src/ComputedDataTemplateString.js b/src/ComputedDataTemplateString.js
index ffa391e6..6bc71268 100644
--- a/src/ComputedDataTemplateString.js
+++ b/src/ComputedDataTemplateString.js
@@ -55,7 +55,12 @@ class ComputedDataTemplateString {
     } catch (e) {
       debug("Computed Data first pass data resolution error: %o", e);
     }
-    return this.findVarsInOutput(output);
+
+    // page.outputPath on serverless urls returns false.
+    if (typeof output === "string") {
+      return this.findVarsInOutput(output);
+    }
+    return [];
   }
 }
 
diff --git a/test/ServerlessUrlTest.js b/test/ServerlessUrlTest.js
new file mode 100644
index 00000000..7b9e41c2
--- /dev/null
+++ b/test/ServerlessUrlTest.js
@@ -0,0 +1,36 @@
+const test = require("ava");
+const ServerlessUrl = require("../src/Filters/ServerlessUrl");
+
+test("serverlessUrl Stringify", (t) => {
+  // Straight string
+  t.is(ServerlessUrl("/test/"), "/test/");
+
+  // Has data param but doesn’t use it
+  t.is(ServerlessUrl("/test/", { id: 1 }), "/test/");
+
+  // Has data param and does use it
+  t.is(ServerlessUrl("/test/:id/", { id: 1 }), "/test/1/");
+
+  // Throws error when it needs a data param
+  t.throws(() => ServerlessUrl("/test/:id/", {}));
+});
+
+test("serverlessUrl Stringify Arrays", (t) => {
+  // Straight string
+  t.deepEqual(ServerlessUrl(["/test/", "/testb/"]), ["/test/", "/testb/"]);
+
+  // Has data param but doesn’t use it
+  t.deepEqual(ServerlessUrl(["/test/", "/testb/"], { id: 1 }), [
+    "/test/",
+    "/testb/",
+  ]);
+
+  // Has data param and does use it
+  t.deepEqual(ServerlessUrl(["/test/:id/", "/testb/:id/"], { id: 1 }), [
+    "/test/1/",
+    "/testb/1/",
+  ]);
+
+  // Throws error when it needs a data param
+  t.throws(() => ServerlessUrl(["/test/:id/", "/testb/:id/"], {}));
+});
diff --git a/test/TemplatePermalinkTest.js b/test/TemplatePermalinkTest.js
index 146a188f..ca1be5d0 100644
--- a/test/TemplatePermalinkTest.js
+++ b/test/TemplatePermalinkTest.js
@@ -171,45 +171,89 @@ test("Permalink Object, just build", (t) => {
 });
 
 test("Permalink Object, serverless URLs", (t) => {
+  // serverless
   t.is(
     new TemplatePermalink({
       serverless: "permalinksubfolder/test.html",
     }).toLink(),
-    false
+    "permalinksubfolder/test.html"
   );
 
   t.is(
     new TemplatePermalink({
       serverless: "permalinksubfolder/test.html",
     }).toHref(),
-    false
+    "permalinksubfolder/test.html"
   );
 
+  // request
   t.is(
     new TemplatePermalink({
       request: "/url/",
     }).toLink(),
-    false
+    "/url/"
   );
 
   t.is(
     new TemplatePermalink({
       request: "/url/",
     }).toHref(),
-    false
+    "/url/"
   );
 
+  // rando
   t.is(
     new TemplatePermalink({
       rando: "/url/",
     }).toLink(),
-    false
+    "/url/"
   );
 
   t.is(
     new TemplatePermalink({
       rando: "/url/",
     }).toHref(),
-    false
+    "/url/"
+  );
+});
+
+test("Permalink Object, combo build and serverless URLs", (t) => {
+  t.is(
+    new TemplatePermalink({
+      build: "/url/",
+      serverless: "/serverless/",
+    }).toLink(),
+    "/url/index.html"
+  );
+
+  t.is(
+    new TemplatePermalink({
+      build: "/url/",
+      serverless: "/serverless/",
+    }).toHref(),
+    "/url/"
+  );
+
+  // reordered, serverless is primary
+  t.is(
+    new TemplatePermalink({
+      serverless: "/serverless/",
+      build: "/url/",
+    }).toLink(),
+    "/serverless/"
   );
+
+  t.is(
+    new TemplatePermalink({
+      serverless: "/serverless/",
+      build: "/url/",
+    }).toHref(),
+    "/serverless/"
+  );
+});
+
+test("Permalink Object, empty object", (t) => {
+  t.is(new TemplatePermalink({}).toLink(), false);
+
+  t.is(new TemplatePermalink({}).toHref(), false);
 });
diff --git a/test/TemplateTest.js b/test/TemplateTest.js
index 06067c47..46ddff51 100644
--- a/test/TemplateTest.js
+++ b/test/TemplateTest.js
@@ -2250,8 +2250,8 @@ test("permalink object without build", async (t) => {
     "./test/stubs/_site"
   );
 
-  t.is(await tmpl.getOutputLink(), false);
-  t.is(await tmpl.getOutputHref(), false);
+  t.is(await tmpl.getOutputLink(), "/url/");
+  t.is(await tmpl.getOutputHref(), "/url/");
 });
 
 test("permalink object _getLink", async (t) => {
@@ -2266,8 +2266,9 @@ test("permalink object _getLink", async (t) => {
       serverless: "/serverless/",
     },
   });
-  t.is(await link.toLink(), false);
-  t.is(await link.toHref(), false);
+  t.is(await link.toLink(), "/serverless/");
+  t.is(await link.toHref(), "/serverless/");
+
   t.deepEqual(link.getServerlessUrls(), {
     serverless: "/serverless/",
   });
@@ -2299,75 +2300,129 @@ test("permalink object _getLink", async (t) => {
   t.deepEqual(tmpl.getServerlessUrls(), {
     serverless: "/serverless/",
   });
+
+  // template syntax
+  let link4 = await tmpl._getLink({
+    test: "a",
+    permalink: {
+      build: "/build{{ test }}/",
+      serverless: "/serverless{{ test }}/",
+    },
+  });
+  t.is(await link4.toLink(), "/builda/index.html");
+  t.is(await link4.toHref(), "/builda/");
+  t.deepEqual(link4.getServerlessUrls(), {
+    serverless: "/serverlessa/",
+  });
+  t.deepEqual(tmpl.getServerlessUrls(), {
+    serverless: "/serverlessa/",
+  });
+});
+
+test("permalink object _getLink (array of serverless URLs)", async (t) => {
+  let tmpl = getNewTemplate(
+    "./test/stubs/permalink-nobuild/permalink-nobuild.md",
+    "./test/stubs/",
+    "./test/stubs/_site"
+  );
+
+  // Array of URLs is supported
+  let link4 = await tmpl._getLink({
+    permalink: {
+      serverless: ["/serverless1/", "/serverless2/"],
+    },
+  });
+  t.is(await link4.toLink(), "/serverless1/");
+  t.is(await link4.toHref(), "/serverless1/");
+
+  t.deepEqual(link4.getServerlessUrls(), {
+    serverless: ["/serverless1/", "/serverless2/"],
+  });
+  t.deepEqual(tmpl.getServerlessUrls(), {
+    serverless: ["/serverless1/", "/serverless2/"],
+  });
+});
+
+test("permalink object _getLink (array of serverless URLs with template syntax)", async (t) => {
+  let tmpl = getNewTemplate(
+    "./test/stubs/permalink-nobuild/permalink-nobuild.md",
+    "./test/stubs/",
+    "./test/stubs/_site"
+  );
+
+  // Array of URLs is supported
+  let link = await tmpl._getLink({
+    test: "a",
+    permalink: {
+      serverless: ["/serverless1{{ test }}/", "/serverless2{{ test }}/"],
+    },
+  });
+  t.is(await link.toLink(), "/serverless1a/");
+  t.is(await link.toHref(), "/serverless1a/");
+
+  t.deepEqual(link.getServerlessUrls(), {
+    serverless: ["/serverless1a/", "/serverless2a/"],
+  });
+  t.deepEqual(tmpl.getServerlessUrls(), {
+    serverless: ["/serverless1a/", "/serverless2a/"],
+  });
 });
 
-test("Resolve page.url from eleventy serverless data", async (t) => {
+test("Do not resolve page.url from eleventy serverless data", async (t) => {
   let tmpl = getNewTemplate(
     "./test/stubs/permalink-build-serverless/permalink-build-serverless.md",
     "./test/stubs/",
     "./test/stubs/_site"
   );
   let fakeData = {
-    eleventy: {
-      serverless: {
-        pathname: "/test/",
-      },
-    },
     permalink: {
-      serverless: "/serverless/", // this is ignored and pathname above is used!
+      serverless: "/serverless/",
     },
   };
   let outputHref = await tmpl.getOutputHref(fakeData);
-  t.is(outputHref, "/test/");
+  t.is(outputHref, "/serverless/");
 
   let outputLink = await tmpl.getOutputLink(fakeData);
-  t.is(outputLink, false);
+  t.is(outputLink, "/serverless/");
 
   let outputPath = await tmpl.getOutputPath(fakeData);
   t.is(outputPath, false);
 
   let { href, link, path } = await tmpl.getOutputLocations(fakeData);
-  t.is(href, "/test/");
-  t.is(link, false);
+  t.is(href, "/serverless/");
+  t.is(link, "/serverless/");
   t.is(path, false);
 });
 
-test("Resolve page.url from eleventy serverless data (when build also exists in permalink)", async (t) => {
+test("Do not resolve page.url from eleventy serverless data (when build also exists in permalink and serverless global data exists)", async (t) => {
   let tmpl = getNewTemplate(
     "./test/stubs/permalink-build-serverless/permalink-build-serverless.md",
     "./test/stubs/",
     "./test/stubs/_site"
   );
   let fakeData = {
-    eleventy: {
-      serverless: {
-        pathname: "/test/",
-      },
-    },
     permalink: {
       build: "/build/",
-      serverless: "/serverless/", // this is ignored and pathname above is used!
+      serverless: "/serverless/",
     },
   };
 
   let outputHref = await tmpl.getOutputHref(fakeData);
-  t.is(outputHref, "/test/");
+  t.is(outputHref, "/build/");
 
-  // These should be false because the eleventy.serverless object is set.
-  // When in build-mode they would not be false.
   let outputLink = await tmpl.getOutputLink(fakeData);
-  t.is(outputLink, false);
+  t.is(outputLink, "/build/index.html");
 
   let outputPath = await tmpl.getOutputPath(fakeData);
-  t.is(outputPath, false);
+  t.is(outputPath, "./test/stubs/_site/build/index.html");
 
   let { href, link, path } = await tmpl.getOutputLocations(fakeData);
-  t.is(href, "/test/");
-  t.is(link, false);
-  t.is(path, false);
+  t.is(href, "/build/");
+  t.is(link, "/build/index.html");
+  t.is(path, "./test/stubs/_site/build/index.html");
 });
 
-test("Do not resolve page.url from eleventy serverless data (when build also exists in permalink)", async (t) => {
+test("Do not resolve page.url from eleventy serverless data (when build also exists in permalink and serverless global data does not exist)", async (t) => {
   let tmpl = getNewTemplate(
     "./test/stubs/permalink-build-serverless/permalink-build-serverless.md",
     "./test/stubs/",
@@ -2404,12 +2459,6 @@ test("Do not override page.url with serverless url", async (t) => {
     "./test/stubs/_site"
   );
   let fakeData = {
-    eleventy: {
-      serverless: {
-        pathname: "/test/",
-        pathnameOverridesPageUrl: false,
-      },
-    },
     permalink: {
       build: "/build/",
       serverless: "/serverless/",
@@ -2419,16 +2468,43 @@ test("Do not override page.url with serverless url", async (t) => {
   let outputHref = await tmpl.getOutputHref(fakeData);
   t.is(outputHref, "/build/");
 
-  // These should be false because the eleventy.serverless object is set.
-  // When in build-mode they would not be false.
   let outputLink = await tmpl.getOutputLink(fakeData);
-  t.is(outputLink, false);
+  t.is(outputLink, "/build/index.html");
 
   let outputPath = await tmpl.getOutputPath(fakeData);
-  t.is(outputPath, false);
+  t.is(outputPath, "./test/stubs/_site/build/index.html");
 
   let { href, link, path } = await tmpl.getOutputLocations(fakeData);
   t.is(href, "/build/");
-  t.is(link, false);
-  t.is(path, false);
+  t.is(link, "/build/index.html");
+  t.is(path, "./test/stubs/_site/build/index.html");
+});
+
+test("Permalink is an object but an empty object (inherit default behavior)", async (t) => {
+  let tmpl = getNewTemplate(
+    "./test/stubs/permalink-empty-object/empty-object.md",
+    "./test/stubs/",
+    "./test/stubs/_site"
+  );
+  let data = await tmpl.getData();
+
+  let outputHref = await tmpl.getOutputHref(data);
+  t.is(outputHref, "/permalink-empty-object/empty-object/");
+
+  let outputLink = await tmpl.getOutputLink(data);
+  t.is(outputLink, "permalink-empty-object/empty-object/index.html");
+
+  let outputPath = await tmpl.getOutputPath(data);
+  t.is(
+    outputPath,
+    "./test/stubs/_site/permalink-empty-object/empty-object/index.html"
+  );
+
+  let { href, link, path } = await tmpl.getOutputLocations(data);
+  t.is(href, "/permalink-empty-object/empty-object/");
+  t.is(link, "permalink-empty-object/empty-object/index.html");
+  t.is(
+    path,
+    "./test/stubs/_site/permalink-empty-object/empty-object/index.html"
+  );
 });
diff --git a/test/stubs/permalink-empty-object/empty-object.md b/test/stubs/permalink-empty-object/empty-object.md
new file mode 100644
index 00000000..3c97bc7a
--- /dev/null
+++ b/test/stubs/permalink-empty-object/empty-object.md
@@ -0,0 +1,5 @@
+---
+permalink: {}
+---
+
+This should be the same as if permalink was not set at all.