diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b675430..3b3dd5d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,3 +1,4 @@ +name: Node Unit Tests on: push: branches-ignore: @@ -8,14 +9,15 @@ jobs: strategy: matrix: os: ["ubuntu-latest", "macos-latest", "windows-latest"] - node: ["16", "18", "20"] + node: ["16", "18", "20", "22"] name: Node.js ${{ matrix.node }} on ${{ matrix.os }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: ${{ matrix.node }} + # cache: npm - run: npm install - run: npm test env: diff --git a/index.js b/index.js index edd622c..1cfa0e2 100644 --- a/index.js +++ b/index.js @@ -1,7 +1,11 @@ const TemplatePath = require("./src/TemplatePath.js"); const isPlainObject = require("./src/IsPlainObject.js"); +const Merge = require("./src/Merge.js"); +const { DeepCopy } = Merge; module.exports = { TemplatePath, isPlainObject, + Merge, + DeepCopy, }; \ No newline at end of file diff --git a/package.json b/package.json index 7123913..9ff1909 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@11ty/eleventy-utils", - "version": "1.0.2", + "version": "1.0.3", "description": "Low level internal utilities to be shared amongst Eleventy projects", "main": "index.js", "files": [ @@ -38,10 +38,7 @@ }, "bugs": "https://github.com/11ty/eleventy-utils/issues", "homepage": "https://github.com/11ty/eleventy-utils/", - "dependencies": { - "normalize-path": "^3.0.0" - }, "devDependencies": { - "ava": "^4.1.0" + "ava": "^6.1.3" } } diff --git a/src/Merge.js b/src/Merge.js new file mode 100644 index 0000000..c3cfbaf --- /dev/null +++ b/src/Merge.js @@ -0,0 +1,84 @@ +"use strict"; +// above is required for Object.freeze to fail correctly. + +const isPlainObject = require("./IsPlainObject.js"); + +const OVERRIDE_PREFIX = "override:"; + +function cleanKey(key, prefix) { + if (prefix && key.startsWith(prefix)) { + return key.slice(prefix.length); + } + return key; +} + +function getMergedItem(target, source, prefixes = {}) { + let { override } = prefixes; + + // 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 (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); + } + } + return target; + } + // number, string, class instance, etc + return source; +} + +// The same as Merge but without override prefixes +function DeepCopy(targetObject, ...sources) { + for (let source of sources) { + if (!source) { + continue; + } + + targetObject = getMergedItem(targetObject, source); + } + return targetObject; +} + +function Merge(target, ...sources) { + // Remove override prefixes from root target. + if (isPlainObject(target)) { + for (let key in target) { + if (key.indexOf(OVERRIDE_PREFIX) === 0) { + target[key.slice(OVERRIDE_PREFIX.length)] = target[key]; + delete target[key]; + } + } + } + + for (let source of sources) { + if (!source) { + continue; + } + + target = getMergedItem(target, source, { + override: OVERRIDE_PREFIX, + }); + } + + return target; +} + +module.exports = Merge; +module.exports.DeepCopy = DeepCopy; diff --git a/src/TemplatePath.js b/src/TemplatePath.js index 0420973..f0673f4 100644 --- a/src/TemplatePath.js +++ b/src/TemplatePath.js @@ -1,5 +1,4 @@ const path = require("path"); -const normalize = require("normalize-path"); const fs = require("fs"); function TemplatePath() {} @@ -59,7 +58,7 @@ TemplatePath.getLastPathSegment = function (path) { // Trim a trailing slash if there is one path = path.replace(/\/$/, ""); - return path.substring(path.lastIndexOf("/") + 1); + return path.slice(path.lastIndexOf("/") + 1); }; /** @@ -93,22 +92,25 @@ TemplatePath.getAllDirs = function (path) { * @returns {String} the normalized path. */ TemplatePath.normalize = function (thePath) { - return normalize(path.normalize(thePath)); + let filePath = path.normalize(thePath).split(path.sep).join("/"); + if(filePath !== "/" && filePath.endsWith("/")) { + return filePath.slice(0, -1); + } + return filePath; }; /** * Joins all given path segments together. * - * It uses Node.js’ [`path.join`][1] method and the [normalize-path][2] package. + * It uses Node.js’ [`path.join`][1] method. * * [1]: https://nodejs.org/api/path.html#path_path_join_paths - * [2]: https://www.npmjs.com/package/normalize-path * * @param {...String} paths - An arbitrary amount of path segments. * @returns {String} the normalized and joined path. */ TemplatePath.join = function (...paths) { - return normalize(path.join(...paths)); + return TemplatePath.normalize(path.join(...paths)); }; /** @@ -236,7 +238,7 @@ TemplatePath.stripLeadingSubPath = function (path, subPath) { subPath = TemplatePath.normalize(subPath); if (subPath !== "." && path.startsWith(subPath)) { - return path.substring(subPath.length + 1); + return path.slice(subPath.length + 1); } return path; diff --git a/test/MergeTest.js b/test/MergeTest.js new file mode 100644 index 0000000..fd7ee84 --- /dev/null +++ b/test/MergeTest.js @@ -0,0 +1,304 @@ +"use strict"; +// above is required for Object.freeze to fail correctly. + +const test = require("ava"); +const Merge = require("../src/Merge.js"); +const { DeepCopy } = Merge; + +test("Shallow Merge", (t) => { + t.deepEqual(Merge({}, {}), {}); + t.deepEqual(Merge({ a: 1 }, { a: 2 }), { a: 2 }); + t.deepEqual(Merge({ a: 1 }, { a: 2 }, undefined), { a: 2 }); + t.deepEqual(Merge({ a: 1 }, { a: 2 }, { a: 3 }), { a: 3 }); + + t.deepEqual(Merge({ a: 1 }, { b: 1 }), { a: 1, b: 1 }); + t.deepEqual(Merge({ a: 1 }, { b: 1 }, { c: 1 }), { a: 1, b: 1, c: 1 }); + + t.deepEqual(Merge({ a: [1] }, { a: [2] }), { a: [1, 2] }); +}); + +test("Doesn’t need to return", (t) => { + var b = { a: 2 }; + Merge(b, { a: 1 }); + t.deepEqual(b, { a: 1 }); +}); + +test("Invalid", (t) => { + t.deepEqual(Merge({}, 1), {}); + t.deepEqual(Merge({}, [1]), {}); + t.deepEqual(Merge({}, "string"), {}); +}); + +test("Non-Object target", (t) => { + t.deepEqual(Merge(1, { a: 1 }), { a: 1 }); + t.deepEqual(Merge([1], { a: 1 }), { a: 1 }); + t.deepEqual(Merge("string", { a: 1 }), { a: 1 }); +}); + +test("Deep", (t) => { + t.deepEqual(Merge({ a: { b: 1 } }, { a: { c: 1 } }), { a: { b: 1, c: 1 } }); + t.deepEqual(Merge({ a: { b: 1 } }, { a: { c: 1 } }, undefined), { + a: { b: 1, c: 1 }, + }); +}); + +test("Deep, override: prefix", (t) => { + t.deepEqual(Merge({ a: { b: [1, 2] } }, { a: { b: [3, 4] } }), { + a: { b: [1, 2, 3, 4] }, + }); + t.deepEqual(Merge({ a: [1] }, { a: [2] }), { a: [1, 2] }); + t.deepEqual(Merge({ a: [1] }, { "override:a": [2] }), { a: [2] }); + t.deepEqual(Merge({ a: { b: [1, 2] } }, { a: { "override:b": [3, 4] } }), { + a: { b: [3, 4] }, + }); +}); + +test("Deep, override: prefix at root", (t) => { + t.deepEqual(Merge({ "override:a": [1] }, { a: [2] }), { a: [1, 2] }); +}); + +test("Deep, override: prefix at other placements", (t) => { + t.deepEqual( + Merge( + { + a: { + a: [1], + }, + }, + { + a: { + a: [2], + }, + } + ), + { + a: { + a: [1, 2], + }, + } + ); + + t.deepEqual( + Merge( + { + a: { + a: [1], + }, + }, + { + a: { + "override:a": [2], + }, + } + ), + { + a: { + a: [2], + }, + } + ); + + t.deepEqual( + Merge( + { + "override:a": { + a: [1], + }, + }, + { + a: { + a: [2], + }, + } + ), + { + a: { + a: [1, 2], + }, + } + ); + + t.deepEqual( + Merge( + { + a: { + a: [1], + b: [1], + }, + }, + { + "override:a": { + a: [2], + }, + } + ), + { + a: { + a: [2], + }, + } + ); + + t.deepEqual( + Merge( + { + a: { + a: { + a: [1], + }, + }, + }, + { + a: { + "override:a": { + a: [2], + }, + }, + } + ), + { + a: { + a: { + a: [2], + }, + }, + } + ); +}); + +test("Edge case from #2470", (t) => { + t.deepEqual( + Merge( + { + a: { + b: { + c: [1], + }, + }, + }, + { + a: { + "override:override:b": { + c: [2], + }, + }, + } + ), + { + a: { + b: { + c: [1], + }, + "override:b": { + c: [2], + }, + }, + } + ); +}); + +test.skip("Edge case from #2684 (multiple conflicting override: props)", (t) => { + t.deepEqual( + Merge( + { + a: { + "override:b": { + c: [1], + }, + }, + }, + { + a: { + "override:b": { + c: [2], + }, + }, + } + ), + { + a: { + b: { + c: [2], + }, + }, + } + ); +}); + +test("Deep, override: empty", (t) => { + t.deepEqual(Merge({}, { a: { b: [3, 4] } }), { a: { b: [3, 4] } }); + t.deepEqual(Merge({}, { a: [2] }), { a: [2] }); + t.deepEqual(Merge({}, { "override:a": [2] }), { a: [2] }); + t.deepEqual(Merge({}, { a: { "override:b": [3, 4] } }), { a: { b: [3, 4] } }); +}); + +test("DeepCopy", (t) => { + t.deepEqual(DeepCopy({}, { a: { b: [3, 4] } }), { a: { b: [3, 4] } }); + t.deepEqual(DeepCopy({}, { a: [2] }), { a: [2] }); + t.deepEqual(DeepCopy({}, { a: [2] }, undefined), { a: [2] }); + t.deepEqual(DeepCopy({}, undefined, { a: [2] }), { a: [2] }); + t.deepEqual(DeepCopy({}, { "override:a": [2] }), { "override:a": [2] }); + t.deepEqual(DeepCopy({}, { a: { "override:b": [3, 4] } }), { + 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", + } + }); +});