Repository: 11ty/eleventy
Pull request #3251 (2024-04-13 21:21:30)

Throws an error on reserved Eleventy data properties in the data cascade.
Right now this includes:

* `pkg` (Object.freeze’d)
* `eleventy` (Object.freeze’d)
* `content`
* `collections`
* `page.date`
* `page.inputPath`
* `page.fileSlug`
* `page.filePathStem`
* `page.outputFileExtension`
* `page.templateSyntax`
* `page.url`
* `page.outputPath`

Escape hatch provided via `eleventyConfig.setFreezeReservedData(false)`. Default is enabled.

#1173 #2824 #3132


Patch:
diff --git a/src/Data/TemplateData.js b/src/Data/TemplateData.js
index fcad9a77..d35f3cba 100644
--- a/src/Data/TemplateData.js
+++ b/src/Data/TemplateData.js
@@ -10,7 +10,13 @@ import TemplateGlob from "../TemplateGlob.js";
 import EleventyExtensionMap from "../EleventyExtensionMap.js";
 import EleventyBaseError from "../Errors/EleventyBaseError.js";
 import TemplateDataInitialGlobalData from "./TemplateDataInitialGlobalData.js";
-import { EleventyImport, EleventyLoadContent } from "../Util/Require.js";
+import { getEleventyPackageJson } from "../Util/ImportJsonSync.js";
+import {
+	EleventyImport,
+	EleventyLoadContent,
+	normalizeFilePathInEleventyPackage,
+} from "../Util/Require.js";
+import { DeepFreeze } from "../Util/DeepFreeze.js";
 
 const { set: lodashSet, get: lodashGet } = lodash;
 const debugWarn = debugUtil("Eleventy:Warnings");
@@ -101,11 +107,16 @@ class TemplateData {
 		this.config = config;
 	}
 
-	async getRawImports() {
+	getRawImports() {
 		try {
-			this.rawImports[this.config.keys.package] = await EleventyImport("package.json", "json");
+			let pkgJson = getEleventyPackageJson();
+			this.rawImports[this.config.keys.package] = pkgJson;
+
+			if (this.config.freezeReservedData) {
+				DeepFreeze(this.rawImports);
+			}
 		} catch (e) {
-			let pkgPath = TemplatePath.absolutePath("package.json");
+			let pkgPath = normalizeFilePathInEleventyPackage("package.json");
 			debug("Could not find and/or require package.json for data preprocessing at %o", pkgPath);
 		}
 
@@ -284,7 +295,7 @@ class TemplateData {
 				);
 
 				let oldData = lodashGet(globalData, objectPathTarget);
-				data = TemplateData.mergeDeep(this.config, oldData, data);
+				data = TemplateData.mergeDeep(this.config.dataDeepMerge, oldData, data);
 			}
 
 			dataFileConflicts[objectPathTargetString] = files[j];
@@ -304,6 +315,7 @@ class TemplateData {
 					if (!("env" in globalData.eleventy)) {
 						globalData.eleventy.env = {};
 					}
+
 					Object.assign(globalData.eleventy.env, this.environmentVariables);
 				}
 
@@ -311,9 +323,15 @@ class TemplateData {
 					if (!("directories" in globalData.eleventy)) {
 						globalData.eleventy.directories = {};
 					}
+
 					Object.assign(globalData.eleventy.directories, this.dirs.getUserspaceInstance());
 				}
 
+				// Reserved
+				if (this.config.freezeReservedData) {
+					DeepFreeze(globalData.eleventy);
+				}
+
 				resolve(globalData);
 			});
 		}
@@ -322,7 +340,7 @@ class TemplateData {
 	}
 
 	async getGlobalData() {
-		let rawImports = await this.getRawImports();
+		let rawImports = this.getRawImports();
 
 		if (!this.globalData) {
 			this.globalData = new Promise(async (resolve) => {
@@ -389,7 +407,7 @@ class TemplateData {
 					}
 					dataSource[key] = path;
 				}
-				TemplateData.mergeDeep(this.config, localData, cleanedDataForPath);
+				TemplateData.mergeDeep(this.config.dataDeepMerge, localData, cleanedDataForPath);
 			}
 		}
 		return localData;
@@ -602,11 +620,11 @@ class TemplateData {
 		return unique(paths).reverse();
 	}
 
-	static mergeDeep(config, target, ...source) {
-		if (config.dataDeepMerge) {
-			return TemplateData.merge(target, ...source);
-		} else {
+	static mergeDeep(deepMerge, target, ...source) {
+		if (!deepMerge && deepMerge !== undefined) {
 			return Object.assign(target, ...source);
+		} else {
+			return TemplateData.merge(target, ...source);
 		}
 	}
 
diff --git a/src/Eleventy.js b/src/Eleventy.js
index 57b6e3b8..671bf065 100644
--- a/src/Eleventy.js
+++ b/src/Eleventy.js
@@ -129,7 +129,7 @@ class Eleventy {
 		this._hasConfigInitialized = false;
 	}
 
-	async initializeConfig() {
+	async initializeConfig(initOverrides) {
 		if (!this.eleventyConfig) {
 			this.eleventyConfig = new TemplateConfig(null, this.options.configPath);
 		} else if (this.options.configPath) {
@@ -160,7 +160,7 @@ class Eleventy {
 		this.initializeEnvironmentVariables(this.env);
 
 		// Async initialization of configuration
-		await this.eleventyConfig.init();
+		await this.eleventyConfig.init(initOverrides);
 
 		/**
 		 * @member {Object} - Initialize Eleventy’s configuration, including the user config file
@@ -428,6 +428,7 @@ class Eleventy {
 		await this.config.events.emit("eleventy.extensionmap", this.extensionMap);
 
 		// eleventyServe is always available, even when not in --serve mode
+		// TODO directorynorm
 		this.eleventyServe.setOutputDir(this.outputDir);
 
 		// TODO
@@ -497,6 +498,7 @@ Verbose Output: ${this.verboseMode}`;
 			source: this.source,
 			runMode: this.runMode,
 		};
+
 		let configPath = this.eleventyConfig.getLocalProjectConfigFile();
 		if (configPath) {
 			let absolutePathToConfig = TemplatePath.absolutePath(configPath);
@@ -508,7 +510,12 @@ Verbose Output: ${this.verboseMode}`;
 		}
 
 		values.source = this.source;
-		values.isServerless = false; // backwards compatibility
+
+		// Backwards compatibility
+		Object.defineProperty(values, "isServerless", {
+			enumerable: false,
+			value: false,
+		});
 
 		return values;
 	}
@@ -519,6 +526,9 @@ Verbose Output: ${this.verboseMode}`;
 	 * @method
 	 */
 	initializeEnvironmentVariables(env) {
+		// Recognize that global data `eleventy.version` is coerced to remove prerelease tags
+		// and this is the raw version (3.0.0 versus 3.0.0-alpha.6).
+		// `eleventy.env.version` does not yet exist (unnecessary)
 		process.env.ELEVENTY_VERSION = Eleventy.getVersion();
 
 		process.env.ELEVENTY_ROOT = env.root;
diff --git a/src/EleventyServe.js b/src/EleventyServe.js
index b0fb3fea..9977dc1b 100644
--- a/src/EleventyServe.js
+++ b/src/EleventyServe.js
@@ -72,6 +72,7 @@ class EleventyServe {
 		}
 	}
 
+	// TODO directorynorm
 	setOutputDir(outputDir) {
 		// TODO check if this is different and if so, restart server (if already running)
 		// This applies if you change the output directory in your config file during watch/serve
diff --git a/src/Template.js b/src/Template.js
index c4566142..ee17b841 100755
--- a/src/Template.js
+++ b/src/Template.js
@@ -22,6 +22,7 @@ import TemplateBehavior from "./TemplateBehavior.js";
 import TemplateContentPrematureUseError from "./Errors/TemplateContentPrematureUseError.js";
 import TemplateContentUnrenderedTemplateError from "./Errors/TemplateContentUnrenderedTemplateError.js";
 import EleventyBaseError from "./Errors/EleventyBaseError.js";
+import ReservedData from "./Util/ReservedData.js";
 
 const { set: lodashSet, get: lodashGet } = lodash;
 const writeFile = util.promisify(fs.writeFile);
@@ -253,7 +254,8 @@ class Template extends TemplateContent {
 	async usePermalinkRoot() {
 		if (this._usePermalinkRoot === undefined) {
 			// TODO this only works with immediate front matter and not data files
-			this._usePermalinkRoot = (await this.getFrontMatterData())[this.config.keys.permalinkRoot];
+			let { data } = await this.getFrontMatterData();
+			this._usePermalinkRoot = data[this.config.keys.permalinkRoot];
 		}
 
 		return this._usePermalinkRoot;
@@ -305,7 +307,8 @@ class Template extends TemplateContent {
 	}
 
 	async _testGetAllLayoutFrontMatterData() {
-		let frontMatterData = await this.getFrontMatterData();
+		let { data: frontMatterData } = await this.getFrontMatterData();
+
 		if (frontMatterData[this.config.keys.layout]) {
 			let layout = this.getLayout(frontMatterData[this.config.keys.layout]);
 			return await layout.getData();
@@ -328,7 +331,7 @@ class Template extends TemplateContent {
 			debugDev("%o getData getTemplateDirectoryData and getGlobalData", this.inputPath);
 		}
 
-		let frontMatterData = await this.getFrontMatterData();
+		let { data: frontMatterData } = await this.getFrontMatterData();
 		let layoutKey =
 			frontMatterData[this.config.keys.layout] ||
 			localData[this.config.keys.layout] ||
@@ -343,47 +346,52 @@ class Template extends TemplateContent {
 			debugDev("%o getData merged layout chain front matter", this.inputPath);
 		}
 
-		let mergedData = TemplateData.mergeDeep(
-			this.config,
-			{},
-			globalData,
-			mergedLayoutData,
-			localData,
-			frontMatterData,
-		);
-		mergedData = await this.addPageDate(mergedData);
-		mergedData = this.addPageData(mergedData);
-		debugDev("%o getData mergedData", this.inputPath);
+		try {
+			let mergedData = TemplateData.mergeDeep(
+				this.config.dataDeepMerge,
+				{},
+				globalData,
+				mergedLayoutData,
+				localData,
+				frontMatterData,
+			);
 
-		this._dataCache = mergedData;
-		return mergedData;
-	}
+			if (this.config.freezeReservedData) {
+				ReservedData.check(mergedData);
+			}
 
-	async addPageDate(data) {
-		if (!("page" in data)) {
-			data.page = {};
-		}
+			await this.addPage(mergedData);
 
-		let newDate = await this.getMappedDate(data);
+			debugDev("%o getData mergedData", this.inputPath);
 
-		if ("page" in data && "date" in data.page) {
-			debug(
-				"Warning: data.page.date is in use (%o) will be overwritten with: %o",
-				data.page.date,
-				newDate,
-			);
-		}
+			this._dataCache = mergedData;
 
-		data.page.date = newDate;
-
-		return data;
+			return mergedData;
+		} catch (e) {
+			if (
+				ReservedData.isReservedDataError(e) ||
+				(e instanceof TypeError &&
+					e.message.startsWith("Cannot add property") &&
+					e.message.endsWith("not extensible"))
+			) {
+				throw new EleventyBaseError(
+					`You attempted to set one of Eleventy’s reserved data property names${e.reservedNames ? `: ${e.reservedNames.join(", ")}` : ""}. You can opt-out of this behavior with \`eleventyConfig.setFreezeReservedData(false)\` or rename/remove the property in your data cascade that conflicts with Eleventy’s reserved property names (e.g. \`eleventy\`, \`pkg\`, and others). Learn more: https://www.11ty.dev/docs/data-eleventy-supplied/`,
+					e,
+				);
+			}
+			throw e;
+		}
 	}
 
-	addPageData(data) {
+	async addPage(data) {
 		if (!("page" in data)) {
 			data.page = {};
 		}
 
+		let newDate = await this.getMappedDate(data);
+
+		// Make sure to keep these keys synchronized in src/Util/ReservedData.js
+		data.page.date = newDate;
 		data.page.inputPath = this.inputPath;
 		data.page.fileSlug = this.fileSlugStr;
 		data.page.filePathStem = this.filePathStem;
@@ -391,8 +399,10 @@ class Template extends TemplateContent {
 		data.page.templateSyntax = this.templateRender.getEnginesList(
 			data[this.config.keys.engineOverride],
 		);
-
-		return data;
+		// data.page.url
+		// data.page.outputPath
+		// data.page.excerpt from gray-matter and Front Matter
+		// data.page.lang from I18nPlugin
 	}
 
 	// Tests only
@@ -542,6 +552,11 @@ class Template extends TemplateContent {
 				this,
 			);
 
+			// Check for reserved properties in computed data
+			if (this.config.freezeReservedData) {
+				ReservedData.check(data[this.config.keys.computed]);
+			}
+
 			// actually add the computed data
 			this._addComputedEntry(this.computedData, data[this.config.keys.computed]);
 
diff --git a/src/TemplateContent.js b/src/TemplateContent.js
index 0850ad94..c935d277 100644
--- a/src/TemplateContent.js
+++ b/src/TemplateContent.js
@@ -149,8 +149,6 @@ class TemplateContent {
 	get frontMatter() {
 		if (this.frontMatterOverride) {
 			return this.frontMatterOverride;
-		} else if (this._frontMatter) {
-			return this._frontMatter;
 		} else {
 			throw new Error(
 				"Unfortunately you’re using code that monkey patched some Eleventy internals and it isn’t async-friendly. Change your code to use the async `read()` method on the template instead!",
@@ -231,7 +229,7 @@ class TemplateContent {
 						// For monkey patchers that used `frontMatter` 🤧
 						// https://github.com/11ty/eleventy/issues/613#issuecomment-999637109
 						// https://github.com/11ty/eleventy/issues/2710#issuecomment-1373854834
-						this._frontMatter = fm;
+						// Removed this._frontMatter monkey patcher help in 3.0.0-alpha.7
 
 						resolve(fm);
 					} else {
@@ -303,9 +301,9 @@ class TemplateContent {
 		return content;
 	}
 
-	// This might only be used in tests
-	async getFrontMatter() {
+	async _testGetFrontMatter() {
 		let fm = this.frontMatterOverride ? this.frontMatterOverride : await this.read();
+
 		return fm;
 	}
 
@@ -321,6 +319,11 @@ class TemplateContent {
 				try {
 					let fm = await this.read();
 
+					// gray-matter isn’t async-friendly but can return a promise from custom front matter
+					if (fm.data instanceof Promise) {
+						fm.data = await fm.data;
+					}
+
 					let extraData = await this.engine.getExtraDataFromFile(this.inputPath);
 
 					let virtualTemplateDefinition = this.getVirtualTemplateDefinition();
@@ -329,10 +332,13 @@ class TemplateContent {
 						virtualTemplateData = virtualTemplateDefinition.data;
 					}
 
-					let data = TemplateData.mergeDeep({}, fm.data, extraData, virtualTemplateData);
-
+					let data = TemplateData.mergeDeep(false, fm.data, extraData, virtualTemplateData);
 					let cleanedData = TemplateData.cleanupData(data);
-					resolve(cleanedData);
+
+					resolve({
+						data: cleanedData,
+						excerpt: fm.excerpt,
+					});
 				} catch (e) {
 					reject(e);
 				}
@@ -343,7 +349,7 @@ class TemplateContent {
 	}
 
 	async getEngineOverride() {
-		let frontMatterData = await this.getFrontMatterData();
+		let { data: frontMatterData } = await this.getFrontMatterData();
 		return frontMatterData[this.config.keys.engineOverride];
 	}
 
diff --git a/src/TemplateLayout.js b/src/TemplateLayout.js
index 13c62844..c26643f2 100644
--- a/src/TemplateLayout.js
+++ b/src/TemplateLayout.js
@@ -67,12 +67,13 @@ class TemplateLayout extends TemplateContent {
 	}
 
 	async getTemplateLayoutMapEntry() {
+		let { data: frontMatterData } = await this.getFrontMatterData();
 		return {
 			// Used by `TemplateLayout.getTemplate()`
 			key: this.dataKeyLayoutPath,
 
 			// used by `this.getData()`
-			frontMatterData: await this.getFrontMatterData(),
+			frontMatterData,
 		};
 	}
 
@@ -147,7 +148,7 @@ class TemplateLayout extends TemplateContent {
 					}
 
 					// Deep merge of layout front matter
-					let data = TemplateData.mergeDeep(this.config, {}, ...dataToMerge);
+					let data = TemplateData.mergeDeep(this.config.dataDeepMerge, {}, ...dataToMerge);
 					delete data[this.config.keys.layout];
 
 					resolve(data);
diff --git a/src/UserConfig.js b/src/UserConfig.js
index ae8a7b6b..fa68e6e9 100644
--- a/src/UserConfig.js
+++ b/src/UserConfig.js
@@ -149,6 +149,8 @@ class UserConfig {
 		};
 
 		this.virtualTemplates = {};
+
+		this.freezeReservedData = true;
 	}
 
 	// compatibleRange is optional in 2.0.0-beta.2
@@ -918,6 +920,11 @@ class UserConfig {
 		this.#setDirectory("layouts", dir);
 	}
 
+	// Some data keywords in Eleventy are reserved, throw an error if an application tries to set these.
+	setFreezeReservedData(bool) {
+		this.freezeReservedData = !!bool;
+	}
+
 	getMergingConfigObject() {
 		let obj = {
 			templateFormats: this.templateFormats,
@@ -972,6 +979,7 @@ class UserConfig {
 			urlTransforms: this.urlTransforms,
 			virtualTemplates: this.virtualTemplates,
 			// `directories` and `directoryAssignments` are merged manually prior to plugin processing
+			freezeReservedData: this.freezeReservedData,
 		};
 
 		if (Array.isArray(this.dataFileSuffixesOverride)) {
diff --git a/src/Util/Merge.js b/src/Util/Merge.js
index 7761c59b..12433762 100644
--- a/src/Util/Merge.js
+++ b/src/Util/Merge.js
@@ -12,18 +12,26 @@ function cleanKey(key, prefix) {
 function getMergedItem(target, source, prefixes = {}) {
 	let { override } = prefixes;
 
-	// deep copy objects to avoid sharing and to effect key renaming
+	// Shortcut for frozen source (if target does not exist)
+	if (!target && isPlainObject(source) && Object.isFrozen(source)) {
+		return source;
+	}
+
+	let sourcePlainObjectShortcut;
 	if (!target && isPlainObject(source)) {
+		// deep copy objects to avoid sharing and to effect key renaming
 		target = {};
+		sourcePlainObjectShortcut = true;
 	}
 
 	if (Array.isArray(target) && Array.isArray(source)) {
 		return target.concat(source);
 	} else if (isPlainObject(target)) {
-		if (isPlainObject(source)) {
+		if (sourcePlainObjectShortcut || isPlainObject(source)) {
 			for (let key in source) {
 				let overrideKey = cleanKey(key, override);
 
+				// An error happens here if the target is frozen
 				target[overrideKey] = getMergedItem(target[key], source[key], prefixes);
 			}
 		}
@@ -60,6 +68,7 @@ function Merge(target, ...sources) {
 		if (!source) {
 			continue;
 		}
+
 		target = getMergedItem(target, source, {
 			override: OVERRIDE_PREFIX,
 		});
diff --git a/src/Util/ReservedData.js b/src/Util/ReservedData.js
new file mode 100644
index 00000000..6cc175f3
--- /dev/null
+++ b/src/Util/ReservedData.js
@@ -0,0 +1,65 @@
+class EleventyReservedDataError extends TypeError {}
+
+class ReservedData {
+	static properties = [
+		// "pkg", // Object.freeze’d upstream
+		// "eleventy", // Object.freeze’d upstream
+		// "page" is only frozen for specific subproperties below
+		"content",
+		"collections",
+	];
+
+	static pageProperties = [
+		"date",
+		"inputPath",
+		"fileSlug",
+		"filePathStem",
+		"outputFileExtension",
+		"templateSyntax",
+		"url",
+		"outputPath",
+		// not yet `excerpt` or `lang` set via front matter and computed data
+	];
+
+	// Check in the data cascade for reserved data properties.
+	static getReservedKeys(data) {
+		let keys = this.properties.filter((key) => {
+			return key in data;
+		});
+
+		if ("page" in data) {
+			if (typeof data.page === "object") {
+				for (let key of this.pageProperties) {
+					if (key in data.page) {
+						keys.push(`page.${key}`);
+					}
+				}
+			} else {
+				// fail `page` when set to non-object values.
+				keys.push("page");
+			}
+		}
+		return keys;
+	}
+
+	static check(data) {
+		let reserved = ReservedData.getReservedKeys(data);
+		if (reserved.length === 0) {
+			return;
+		}
+
+		let error = new EleventyReservedDataError(
+			`Cannot override reserved Eleventy properties: ${reserved.join(", ")}`,
+		);
+
+		error.reservedNames = reserved;
+
+		throw error;
+	}
+
+	static isReservedDataError(e) {
+		return e instanceof EleventyReservedDataError;
+	}
+}
+
+export default ReservedData;


Test patch:
diff --git a/test/EleventyTest.js b/test/EleventyTest.js
index 3a577a7f..77fdc40a 100644
--- a/test/EleventyTest.js
+++ b/test/EleventyTest.js
@@ -948,3 +948,147 @@ test("Eleventy config export (CommonJS)", async (t) => {
 
   let result = await elev.toJSON();
 });
+
+test("Eleventy setting reserved data throws error (eleventy)", async (t) => {
+  let elev = new Eleventy("./test/stubs-virtual/", undefined, {
+    config: eleventyConfig => {
+      eleventyConfig.addTemplate("index.html", `---
+eleventy:
+  key1: NOOOOO
+---`);
+    }
+  });
+  elev.disableLogger();
+
+  let e = await t.throwsAsync(() => elev.toJSON(), {
+    message: 'You attempted to set one of Eleventy’s reserved data property names. You can opt-out of this behavior with `eleventyConfig.setFreezeReservedData(false)` or rename/remove the property in your data cascade that conflicts with Eleventy’s reserved property names (e.g. `eleventy`, `pkg`, and others). Learn more: https://www.11ty.dev/docs/data-eleventy-supplied/'
+  });
+
+  t.is(e.originalError.toString(), "TypeError: Cannot add property key1, object is not extensible");
+});
+
+test("Eleventy setting reserved data throws error (pkg)", async (t) => {
+  let elev = new Eleventy("./test/stubs-virtual/", undefined, {
+    config: eleventyConfig => {
+      eleventyConfig.addTemplate("index.html", `---
+pkg:
+  myOwn: OVERRIDE
+---`);
+    }
+  });
+  elev.disableLogger();
+
+  let e = await t.throwsAsync(() => elev.toJSON(), {
+    message: 'You attempted to set one of Eleventy’s reserved data property names. You can opt-out of this behavior with `eleventyConfig.setFreezeReservedData(false)` or rename/remove the property in your data cascade that conflicts with Eleventy’s reserved property names (e.g. `eleventy`, `pkg`, and others). Learn more: https://www.11ty.dev/docs/data-eleventy-supplied/'
+  });
+
+  t.is(e.originalError.toString(), "TypeError: Cannot add property myOwn, object is not extensible");
+});
+
+test("Eleventy setting reserved data throws error (page)", async (t) => {
+  let elev = new Eleventy("./test/stubs-virtual/", undefined, {
+    config: eleventyConfig => {
+      eleventyConfig.addTemplate("index.html", `---
+page: "My page value"
+---`)
+    }
+  });
+  elev.disableLogger();
+
+  let e = await t.throwsAsync(() => elev.toJSON(), {
+    message: 'You attempted to set one of Eleventy’s reserved data property names: page. You can opt-out of this behavior with `eleventyConfig.setFreezeReservedData(false)` or rename/remove the property in your data cascade that conflicts with Eleventy’s reserved property names (e.g. `eleventy`, `pkg`, and others). Learn more: https://www.11ty.dev/docs/data-eleventy-supplied/'
+  });
+
+  t.is(e.originalError.toString(), "TypeError: Cannot override reserved Eleventy properties: page");
+});
+
+test("Eleventy setting reserved data throws error (content)", async (t) => {
+  let elev = new Eleventy("./test/stubs-virtual/", undefined, {
+    config: eleventyConfig => {
+      eleventyConfig.addTemplate("index.html", `---
+content: "My page value"
+---`)
+    }
+  });
+  elev.disableLogger();
+
+  let e = await t.throwsAsync(() => elev.toJSON(), {
+    message: 'You attempted to set one of Eleventy’s reserved data property names: content. You can opt-out of this behavior with `eleventyConfig.setFreezeReservedData(false)` or rename/remove the property in your data cascade that conflicts with Eleventy’s reserved property names (e.g. `eleventy`, `pkg`, and others). Learn more: https://www.11ty.dev/docs/data-eleventy-supplied/'
+  });
+
+  t.is(e.originalError.toString(), "TypeError: Cannot override reserved Eleventy properties: content");
+});
+
+test("Eleventy setting reserved data throws error (collections)", async (t) => {
+  let elev = new Eleventy("./test/stubs-virtual/", undefined, {
+    config: eleventyConfig => {
+      eleventyConfig.addTemplate("index.html", `---
+collections: []
+---`)
+    }
+  });
+  elev.disableLogger();
+
+  let e = await t.throwsAsync(() => elev.toJSON(), {
+    message: 'You attempted to set one of Eleventy’s reserved data property names: collections. You can opt-out of this behavior with `eleventyConfig.setFreezeReservedData(false)` or rename/remove the property in your data cascade that conflicts with Eleventy’s reserved property names (e.g. `eleventy`, `pkg`, and others). Learn more: https://www.11ty.dev/docs/data-eleventy-supplied/'
+  });
+
+  t.is(e.originalError.toString(), "TypeError: Cannot override reserved Eleventy properties: collections");
+});
+
+test("Eleventy setting pkg data is okay when pkg is remapped to parkour", async (t) => {
+  let elev = new Eleventy("./test/stubs-virtual/", undefined, {
+    config: eleventyConfig => {
+      eleventyConfig.addTemplate("index.html", `---
+pkg:
+  myOwn: OVERRIDE
+---`);
+    }
+  });
+  elev.disableLogger();
+
+  await elev.initializeConfig({
+    keys: {
+      package: "parkour"
+    }
+  });
+
+  // Remap successful
+  t.is(elev.eleventyConfig.config.keys.package, "parkour");
+
+  let [result] = await elev.toJSON();
+  t.deepEqual(result, {
+    content: "",
+    inputPath: "./test/stubs-virtual/index.html",
+    outputPath: "./_site/index.html",
+    rawInput: "",
+    url: "/"
+  });
+});
+
+test("Eleventy setting reserved data throws error (pkg remapped to parkour)", async (t) => {
+  let elev = new Eleventy("./test/stubs-virtual/", undefined, {
+    config: eleventyConfig => {
+      eleventyConfig.addTemplate("index.html", `---
+parkour:
+  myOwn: OVERRIDE
+---`);
+    }
+  });
+  elev.disableLogger();
+
+  await elev.initializeConfig({
+    keys: {
+      package: "parkour"
+    }
+  });
+
+  // Remap successful
+  t.is(elev.eleventyConfig.config.keys.package, "parkour");
+
+  let e = await t.throwsAsync(() => elev.toJSON(), {
+    message: 'You attempted to set one of Eleventy’s reserved data property names. You can opt-out of this behavior with `eleventyConfig.setFreezeReservedData(false)` or rename/remove the property in your data cascade that conflicts with Eleventy’s reserved property names (e.g. `eleventy`, `pkg`, and others). Learn more: https://www.11ty.dev/docs/data-eleventy-supplied/'
+  });
+
+  t.is(e.originalError.toString(), "TypeError: Cannot add property myOwn, object is not extensible");
+});
diff --git a/test/JavaScriptFrontMatterTest.js b/test/JavaScriptFrontMatterTest.js
index 74e21645..07a1a290 100644
--- a/test/JavaScriptFrontMatterTest.js
+++ b/test/JavaScriptFrontMatterTest.js
@@ -3,7 +3,7 @@ import Eleventy from "../src/Eleventy.js";
 
 test("Custom Front Matter Parsing Options (using JavaScript node-retrieve-globals)", async (t) => {
   let elev = new Eleventy("./test/stubs/script-frontmatter/test.njk", "./_site");
-  elev.setIsVerbose(false);
+  elev.disableLogger();
 
   let result = await elev.toJSON();
 
@@ -20,7 +20,7 @@ test("Custom Front Matter Parsing Options (using JavaScript node-retrieve-global
       });
     },
   });
-  elev.setIsVerbose(false);
+  elev.disableLogger();
 
   let result = await elev.toJSON();
 
diff --git a/test/MergeTest.js b/test/MergeTest.js
index 8753f30c..42e7bcfe 100644
--- a/test/MergeTest.js
+++ b/test/MergeTest.js
@@ -239,3 +239,64 @@ test("DeepCopy", (t) => {
     a: { "override:b": [3, 4] },
   });
 });
+
+test("String does not overrides parent key with object", (t) => {
+  t.deepEqual(Merge({
+    eleventy: {
+      key1: "a"
+    }
+  }, {
+    // this is ignored
+    eleventy: "string"
+  }), {
+    eleventy: {
+      key1: "a"
+    }
+  });
+});
+
+
+test("Merge with frozen target object fails", (t) => {
+  t.throws(() => {
+    Merge({
+      eleventy: Object.freeze({
+        key1: "a"
+      })
+    }, {
+      eleventy: {
+        key2: "b"
+      }
+    });
+  });
+});
+
+test("Merge with frozen source object (1 level deep) succeeds", (t) => {
+  t.deepEqual(Merge({
+  }, {
+    eleventy: Object.freeze({
+      key2: "b"
+    })
+  }), {
+    eleventy: {
+      key2: "b",
+    }
+  });
+});
+
+
+test("Merge with frozen source object (1 level deep, mixed) succeeds", (t) => {
+  t.deepEqual(Merge({
+    eleventy: {
+      key1: "a"
+    }
+  }, {
+    eleventy: Object.freeze({
+      key2: "b"
+    })
+  }), {
+    eleventy: {
+      key1: "a",
+      key2: "b",
+    }
+  });
+});
diff --git a/test/ReservedDataTest.js b/test/ReservedDataTest.js
new file mode 100644
index 00000000..f75c270b
--- /dev/null
+++ b/test/ReservedDataTest.js
@@ -0,0 +1,26 @@
+import test from "ava";
+import ReservedData from "../src/Util/ReservedData.js";
+
+test("No reserved Keys", t => {
+  t.deepEqual(ReservedData.getReservedKeys({ key: {} }).sort(), []);
+});
+
+test("Standard keys are reserved", t => {
+  t.deepEqual(ReservedData.getReservedKeys({ content: "" }).sort(), ["content"]);
+  t.deepEqual(ReservedData.getReservedKeys({ collections: {} }).sort(), ["collections"]);
+  t.deepEqual(ReservedData.getReservedKeys({ content: "", collections: {} }).sort(), ["collections", "content"]);
+});
+
+test("`page` subkeys", t => {
+  t.deepEqual(ReservedData.getReservedKeys({ page: {} }).sort(), []);
+  t.deepEqual(ReservedData.getReservedKeys({ page: "" }).sort(), ["page"]);
+  t.deepEqual(ReservedData.getReservedKeys({ page: { date: "", otherkey: "" } }).sort(), ["page.date"]);
+  t.deepEqual(ReservedData.getReservedKeys({ page: { inputPath: "", otherkey: "" } }).sort(), ["page.inputPath"]);
+  t.deepEqual(ReservedData.getReservedKeys({ page: { fileSlug: "", otherkey: "" } }).sort(), ["page.fileSlug"]);
+  t.deepEqual(ReservedData.getReservedKeys({ page: { filePathStem: "", otherkey: "" } }).sort(), ["page.filePathStem"]);
+  t.deepEqual(ReservedData.getReservedKeys({ page: { outputFileExtension: "", otherkey: "" } }).sort(), ["page.outputFileExtension"]);
+  t.deepEqual(ReservedData.getReservedKeys({ page: { templateSyntax: "", otherkey: "" } }).sort(), ["page.templateSyntax"]);
+  t.deepEqual(ReservedData.getReservedKeys({ page: { url: "", otherkey: "" } }).sort(), ["page.url"]);
+  t.deepEqual(ReservedData.getReservedKeys({ page: { outputPath: "", otherkey: "" } }).sort(), ["page.outputPath"]);
+  t.deepEqual(ReservedData.getReservedKeys({ page: { date: "", outputPath: "", otherkey: "" } }).sort(), ["page.date", "page.outputPath"]);
+});
diff --git a/test/TemplateRenderPluginTest.js b/test/TemplateRenderPluginTest.js
index 03885dca..4db9b9b9 100644
--- a/test/TemplateRenderPluginTest.js
+++ b/test/TemplateRenderPluginTest.js
@@ -290,12 +290,12 @@ test("Use page in renderTemplate (njk in liquid)", async (t) => {
 
 test("Use eleventy in renderTemplate (njk in liquid)", async (t) => {
   let html = await getTestOutputForFile("./test/stubs-render-plugin/njk-eleventy.liquid");
-  t.is(html, `1`);
+  t.is(html, "3.0.0");
 });
 
 test("Use eleventy in renderTemplate (liquid in njk)", async (t) => {
   let html = await getTestOutputForFile("./test/stubs-render-plugin/liquid-eleventy.njk");
-  t.is(html, `1`);
+  t.is(html, "3.0.0");
 });
 test.skip("Use nunjucks in liquid (access to all global data)", async (t) => {
   let html = await getTestOutputForFile("./test/stubs-render-plugin/nunjucks-global.liquid");
diff --git a/test/TemplateTest.js b/test/TemplateTest.js
index 204fa1c3..3d077d26 100644
--- a/test/TemplateTest.js
+++ b/test/TemplateTest.js
@@ -126,8 +126,8 @@ test("Test raw front matter from template (yaml)", async (t) => {
   );
   t.truthy(await tmpl.getInputContent(), "template exists and can be opened.");
 
-  t.is((await tmpl.getFrontMatter()).data.key1, "value1");
-  t.is((await tmpl.getFrontMatter()).data.key3, "value3");
+  t.is((await tmpl._testGetFrontMatter()).data.key1, "value1");
+  t.is((await tmpl._testGetFrontMatter()).data.key3, "value3");
 
   let data = await tmpl.getData();
   t.is(data.key1, "value1");
@@ -145,8 +145,8 @@ test("Test raw front matter from template (json)", async (t) => {
     "./dist"
   );
 
-  t.is((await tmpl.getFrontMatter()).data.key1, "value1");
-  t.is((await tmpl.getFrontMatter()).data.key3, "value3");
+  t.is((await tmpl._testGetFrontMatter()).data.key1, "value1");
+  t.is((await tmpl._testGetFrontMatter()).data.key3, "value3");
 
   let data = await tmpl.getData();
   t.is(data.key1, "value1");
@@ -164,8 +164,8 @@ test("Test raw front matter from template (js)", async (t) => {
     "./dist"
   );
 
-  t.is((await tmpl.getFrontMatter()).data.key1, "value1");
-  t.is((await tmpl.getFrontMatter()).data.key3, "value3");
+  t.is((await tmpl._testGetFrontMatter()).data.key1, "value1");
+  t.is((await tmpl._testGetFrontMatter()).data.key3, "value3");
 
   let data = await tmpl.getData();
   t.is(data.key1, "value1");
@@ -205,7 +205,7 @@ test("One Layout (using new content var)", async (t) => {
     eleventyConfig
   );
 
-  t.is((await tmpl.getFrontMatter()).data[tmpl.config.keys.layout], "defaultLayout");
+  t.is((await tmpl._testGetFrontMatter()).data[tmpl.config.keys.layout], "defaultLayout");
 
   let data = await tmpl.getData();
   t.is(data[tmpl.config.keys.layout], "defaultLayout");
@@ -239,7 +239,7 @@ test("One Layout (using content)", async (t) => {
     eleventyConfig
   );
 
-  t.is((await tmpl.getFrontMatter()).data[tmpl.config.keys.layout], "defaultLayoutLayoutContent");
+  t.is((await tmpl._testGetFrontMatter()).data[tmpl.config.keys.layout], "defaultLayoutLayoutContent");
 
   let data = await tmpl.getData();
   t.is(data[tmpl.config.keys.layout], "defaultLayoutLayoutContent");
@@ -273,7 +273,7 @@ test("One Layout (layouts disabled)", async (t) => {
     eleventyConfig
   );
 
-  t.is((await tmpl.getFrontMatter()).data[tmpl.config.keys.layout], "defaultLayoutLayoutContent");
+  t.is((await tmpl._testGetFrontMatter()).data[tmpl.config.keys.layout], "defaultLayoutLayoutContent");
 
   let data = await tmpl.getData();
   t.is(data[tmpl.config.keys.layout], "defaultLayoutLayoutContent");
@@ -305,7 +305,7 @@ test("One Layout (liquid test)", async (t) => {
     eleventyConfig
   );
 
-  t.is((await tmpl.getFrontMatter()).data[tmpl.config.keys.layout], "layoutLiquid.liquid");
+  t.is((await tmpl._testGetFrontMatter()).data[tmpl.config.keys.layout], "layoutLiquid.liquid");
 
   let data = await tmpl.getData();
   t.is(data[tmpl.config.keys.layout], "layoutLiquid.liquid");
@@ -339,7 +339,7 @@ test("Two Layouts", async (t) => {
     eleventyConfig
   );
 
-  t.is((await tmpl.getFrontMatter()).data[tmpl.config.keys.layout], "layout-a");
+  t.is((await tmpl._testGetFrontMatter()).data[tmpl.config.keys.layout], "layout-a");
 
   let data = await tmpl.getData();
   t.is(data[tmpl.config.keys.layout], "layout-a");
@@ -1065,7 +1065,7 @@ test("Front Matter Tags (Single)", async (t) => {
     "./test/stubs/",
     "dist"
   );
-  let frontmatter = await tmpl.getFrontMatterData();
+  let { data: frontmatter } = await tmpl.getFrontMatterData();
   t.deepEqual(frontmatter.tags, ["single-tag"]);
 
   let fulldata = await tmpl.getData();
@@ -1081,7 +1081,7 @@ test("Front Matter Tags (Multiple)", async (t) => {
     "./test/stubs/",
     "dist"
   );
-  let frontmatter = await tmpl.getFrontMatterData();
+  let { data: frontmatter } = await tmpl.getFrontMatterData();
   t.deepEqual(frontmatter.tags, ["multi-tag", "multi-tag-2"]);
 
   let fulldata = await tmpl.getData();
@@ -1442,13 +1442,11 @@ test("Issue 413 weird date format", async (t) => {
     "./dist"
   );
 
-  let error = await t.throwsAsync(async function () {
+  await t.throwsAsync(async function () {
     await tmpl.getData();
+  }, {
+    message: "date front matter value (2019-03-13 20:18:42 +0000) is invalid for ./test/stubs-413/date-frontmatter.md"
   });
-
-  t.truthy(
-    error.message.indexOf("date front matter value (2019-03-13 20:18:42 +0000) is invalid") > -1
-  );
 });
 
 test("Custom Front Matter Parsing Options", async (t) => {
@@ -1461,9 +1459,9 @@ test("Custom Front Matter Parsing Options", async (t) => {
     excerpt: true,
   };
 
-  let frontmatter = await tmpl.getFrontMatter();
+  let frontmatter = await tmpl._testGetFrontMatter();
+
   t.is(frontmatter.data.front, "hello");
-  t.is(frontmatter.data.page.excerpt.trim(), "This is an excerpt.");
 
   t.is(frontmatter.excerpt.trim(), "This is an excerpt.");
   t.is(
@@ -1487,9 +1485,9 @@ test("Custom Front Matter Parsing Options (using alias)", async (t) => {
     excerpt_alias: "my_excerpt",
   };
 
-  let frontmatter = await tmpl.getFrontMatter();
+  let frontmatter = await tmpl._testGetFrontMatter();
   t.is(frontmatter.data.front, "hello");
-  t.is(frontmatter.data.my_excerpt.trim(), "This is an excerpt.");
+
   t.is(
     normalizeNewLines(frontmatter.content.trim()),
     `This is an excerpt.
@@ -1510,9 +1508,8 @@ test("Custom Front Matter Parsing Options (no newline before excerpt separator)"
     excerpt: true,
   };
 
-  let frontmatter = await tmpl.getFrontMatter();
+  let frontmatter = await tmpl._testGetFrontMatter();
   t.is(frontmatter.data.front, "hello");
-  t.is(frontmatter.data.page.excerpt.trim(), "This is an excerpt.");
 
   t.is(frontmatter.excerpt.trim(), "This is an excerpt.");
   t.is(
@@ -1535,7 +1532,7 @@ test("Custom Front Matter Parsing Options (no newline after excerpt separator)",
     excerpt: true,
   };
 
-  let frontmatter = await tmpl.getFrontMatter();
+  let frontmatter = await tmpl._testGetFrontMatter();
   t.is(
     normalizeNewLines(frontmatter.content.trim()),
     `This is an excerpt.
@@ -1553,7 +1550,7 @@ test("Custom Front Matter Parsing Options (no newlines before or after excerpt s
     excerpt: true,
   };
 
-  let frontmatter = await tmpl.getFrontMatter();
+  let frontmatter = await tmpl._testGetFrontMatter();
   t.is(frontmatter.content.trim(), "This is an excerpt.This is content.");
 });
 
@@ -1568,9 +1565,8 @@ test("Custom Front Matter Parsing Options (html comment separator)", async (t) =
     excerpt_separator: "<!-- excerpt -->",
   };
 
-  let frontmatter = await tmpl.getFrontMatter();
+  let frontmatter = await tmpl._testGetFrontMatter();
   t.is(frontmatter.data.front, "hello");
-  t.is(frontmatter.data.page.excerpt.trim(), "This is an excerpt.");
 
   t.is(frontmatter.excerpt.trim(), "This is an excerpt.");
   t.is(
@@ -1578,6 +1574,9 @@ test("Custom Front Matter Parsing Options (html comment separator)", async (t) =
     `This is an excerpt.
 This is content.`
   );
+
+  let fulldata = await tmpl.getData();
+  t.is(fulldata.page.excerpt.trim(), "This is an excerpt.");
 });
 
 test("Custom Front Matter Parsing Options (using TOML)", async (t) => {
@@ -1593,7 +1592,7 @@ test("Custom Front Matter Parsing Options (using TOML)", async (t) => {
     },
   };
 
-  let frontmatter = await tmpl.getFrontMatter();
+  let frontmatter = await tmpl._testGetFrontMatter();
   t.deepEqual(frontmatter.data, {
     front: "hello",
   });
diff --git a/test/stubs-render-plugin/liquid-eleventy.njk b/test/stubs-render-plugin/liquid-eleventy.njk
index a76aafcc..24c77d7e 100644
--- a/test/stubs-render-plugin/liquid-eleventy.njk
+++ b/test/stubs-render-plugin/liquid-eleventy.njk
@@ -1,7 +1,5 @@
 ---
-eleventy:
-  test: 1
 ---
 {% renderTemplate "liquid" %}
-{{ eleventy.test }}
+{{ eleventy.version }}
 {% endrenderTemplate %}
diff --git a/test/stubs-render-plugin/njk-eleventy.liquid b/test/stubs-render-plugin/njk-eleventy.liquid
index b7f218c6..5cca5144 100644
--- a/test/stubs-render-plugin/njk-eleventy.liquid
+++ b/test/stubs-render-plugin/njk-eleventy.liquid
@@ -1,7 +1,5 @@
 ---
-eleventy:
-  test: 1
 ---
 {% renderTemplate "njk" %}
-{{ eleventy.test }}
+{{ eleventy.version }}
 {% endrenderTemplate %}