@weborigami/async-tree 0.2.4 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weborigami/async-tree",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "description": "Asynchronous tree drivers based on standard JavaScript classes",
5
5
  "type": "module",
6
6
  "main": "./main.js",
@@ -11,7 +11,7 @@
11
11
  "typescript": "5.7.2"
12
12
  },
13
13
  "dependencies": {
14
- "@weborigami/types": "0.2.4"
14
+ "@weborigami/types": "0.2.5"
15
15
  },
16
16
  "scripts": {
17
17
  "test": "node --test --test-reporter=spec",
package/shared.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // Exports for both Node.js and browser
2
2
 
3
3
  export { default as calendarTree } from "./src/drivers/calendarTree.js";
4
+ export { default as constantTree } from "./src/drivers/constantTree.js";
4
5
  export { default as DeepMapTree } from "./src/drivers/DeepMapTree.js";
5
6
  export { default as DeferredTree } from "./src/drivers/DeferredTree.js";
6
7
  export { default as ExplorableSiteTree } from "./src/drivers/ExplorableSiteTree.js";
@@ -18,6 +19,7 @@ export { default as deepReverse } from "./src/operations/deepReverse.js";
18
19
  export { default as deepTake } from "./src/operations/deepTake.js";
19
20
  export { default as deepValues } from "./src/operations/deepValues.js";
20
21
  export { default as deepValuesIterator } from "./src/operations/deepValuesIterator.js";
22
+ export { default as filter } from "./src/operations/filter.js";
21
23
  export { default as group } from "./src/operations/group.js";
22
24
  export { default as invokeFunctions } from "./src/operations/invokeFunctions.js";
23
25
  export { default as keyFunctionsForExtensions } from "./src/operations/keyFunctionsForExtensions.js";
package/src/Tree.js CHANGED
@@ -422,7 +422,7 @@ export async function traverseOrThrow(treelike, ...keys) {
422
422
  const remainingKeys = keys.slice();
423
423
  let key;
424
424
  while (remainingKeys.length > 0) {
425
- if (value === undefined) {
425
+ if (value == null) {
426
426
  throw new TraverseError("A null or undefined value can't be traversed", {
427
427
  tree: treelike,
428
428
  keys,
@@ -443,20 +443,13 @@ export async function traverseOrThrow(treelike, ...keys) {
443
443
  const args = remainingKeys.splice(0, fnKeyCount);
444
444
  key = null;
445
445
  value = await fn.call(target, ...args);
446
- } else if (isTraversable(value) || typeof value === "object") {
447
- // Value is some other treelike object: cast it to a tree.
446
+ } else {
447
+ // Cast value to a tree.
448
448
  const tree = from(value);
449
449
  // Get the next key.
450
450
  key = remainingKeys.shift();
451
451
  // Get the value for the key.
452
452
  value = await tree.get(key);
453
- } else {
454
- // Value can't be traversed
455
- throw new TraverseError("Tried to traverse a value that's not treelike", {
456
- tree: treelike,
457
- keys,
458
- position,
459
- });
460
453
  }
461
454
 
462
455
  position++;
@@ -0,0 +1,19 @@
1
+ import { trailingSlash } from "../../main.js";
2
+
3
+ /**
4
+ * A tree that returns a constant value for any key. If the key ends with a
5
+ * slash, then the same type of subtree is returned.
6
+ *
7
+ * @param {any} constant
8
+ */
9
+ export default function constantTree(constant) {
10
+ return {
11
+ async get(key) {
12
+ return trailingSlash.has(key) ? constantTree(constant) : constant;
13
+ },
14
+
15
+ async keys() {
16
+ return [];
17
+ },
18
+ };
19
+ }
@@ -6,23 +6,15 @@ import { ObjectTree, Tree } from "../internal.js";
6
6
  *
7
7
  * If no second tree is supplied, an in-memory value cache is used.
8
8
  *
9
- * An optional third filter tree can be supplied. If a filter tree is supplied,
10
- * only values for keys that match the filter will be cached.
11
- *
12
9
  * @typedef {import("@weborigami/types").AsyncTree} AsyncTree
13
10
  * @typedef {import("@weborigami/types").AsyncMutableTree} AsyncMutableTree
14
11
  * @typedef {import("../../index.ts").Treelike} Treelike
15
12
  *
16
13
  * @param {Treelike} sourceTreelike
17
14
  * @param {AsyncMutableTree} [cacheTreelike]
18
- * @param {Treelike} [filterTreelike]
19
15
  * @returns {AsyncTree & { description: string }}
20
16
  */
21
- export default function treeCache(
22
- sourceTreelike,
23
- cacheTreelike,
24
- filterTreelike
25
- ) {
17
+ export default function treeCache(sourceTreelike, cacheTreelike) {
26
18
  if (!sourceTreelike) {
27
19
  const error = new TypeError(`cache: The source tree isn't defined.`);
28
20
  /** @type {any} */ (error).position = 0;
@@ -30,7 +22,6 @@ export default function treeCache(
30
22
  }
31
23
 
32
24
  const source = Tree.from(sourceTreelike);
33
- const filter = filterTreelike ? Tree.from(filterTreelike) : undefined;
34
25
 
35
26
  /** @type {AsyncMutableTree} */
36
27
  let cache;
@@ -58,36 +49,27 @@ export default function treeCache(
58
49
 
59
50
  // Cache miss or interior node cache hit.
60
51
  let value = await source.get(key);
61
- if (value !== undefined) {
62
- // If a filter is defined, does the key match the filter?
63
- const filterValue = filter ? await filter.get(key) : undefined;
64
- const filterMatch = !filter || filterValue !== undefined;
65
- if (filterMatch) {
66
- if (Tree.isAsyncTree(value)) {
67
- // Construct merged tree for a tree result.
68
- if (cacheValue === undefined) {
69
- // Construct new empty container in cache
70
- await cache.set(key, {});
71
- cacheValue = await cache.get(key);
72
- if (!Tree.isAsyncTree(cacheValue)) {
73
- // Coerce to tree and then save it back to the cache. This is
74
- // necessary, e.g., if cache is an ObjectTree; we want the
75
- // subtree to also be an ObjectTree, not a plain object.
76
- cacheValue = Tree.from(cacheValue);
77
- await cache.set(key, cacheValue);
78
- }
79
- }
80
- value = treeCache(value, cacheValue, filterValue);
81
- } else {
82
- // Save in cache before returning.
83
- await cache.set(key, value);
52
+ if (Tree.isAsyncTree(value)) {
53
+ // Construct merged tree for a tree result.
54
+ if (cacheValue === undefined) {
55
+ // Construct new empty container in cache
56
+ await cache.set(key, {});
57
+ cacheValue = await cache.get(key);
58
+ if (!Tree.isAsyncTree(cacheValue)) {
59
+ // Coerce to tree and then save it back to the cache. This is
60
+ // necessary, e.g., if cache is an ObjectTree; we want the
61
+ // subtree to also be an ObjectTree, not a plain object.
62
+ cacheValue = Tree.from(cacheValue);
63
+ await cache.set(key, cacheValue);
84
64
  }
85
65
  }
86
-
87
- return value;
66
+ value = treeCache(value, cacheValue);
67
+ } else if (value !== undefined) {
68
+ // Save in cache before returning.
69
+ await cache.set(key, value);
88
70
  }
89
71
 
90
- return undefined;
72
+ return value;
91
73
  },
92
74
 
93
75
  async keys() {
@@ -21,10 +21,13 @@ export default function deepMerge(...sources) {
21
21
  for (let index = trees.length - 1; index >= 0; index--) {
22
22
  const tree = trees[index];
23
23
  const value = await tree.get(key);
24
- if (Tree.isAsyncTree(value)) {
25
- if (value.parent === tree) {
24
+ if (
25
+ Tree.isAsyncTree(value) ||
26
+ (Tree.isTreelike(value) && trailingSlash.has(key))
27
+ ) {
28
+ if (/** @type {any} */ (value).parent === tree) {
26
29
  // Merged tree acts as parent instead of the source tree.
27
- value.parent = this;
30
+ /** @type {any} */ (value).parent = this;
28
31
  }
29
32
  subtrees.unshift(value);
30
33
  } else if (value !== undefined) {
@@ -0,0 +1,51 @@
1
+ import { trailingSlash, Tree } from "@weborigami/async-tree";
2
+
3
+ /**
4
+ * Given trees `a` and `b`, return a filtered version of `a` where only the keys
5
+ * that exist in `b` and have truthy values are kept. The filter operation is
6
+ * deep: if a value from `a` is a subtree, it will be filtered recursively.
7
+ *
8
+ * @typedef {import("@weborigami/types").AsyncTree} AsyncTree
9
+ * @typedef {import("../../index.ts").Treelike} Treelike
10
+ *
11
+ * @param {Treelike} a
12
+ * @param {Treelike} b
13
+ * @returns {AsyncTree}
14
+ */
15
+ export default function filter(a, b) {
16
+ a = Tree.from(a);
17
+ b = Tree.from(b, { deep: true });
18
+
19
+ return {
20
+ async get(key) {
21
+ // The key must exist in b and return a truthy value
22
+ const bValue = await b.get(key);
23
+ if (!bValue) {
24
+ return undefined;
25
+ }
26
+ let aValue = await a.get(key);
27
+ if (Tree.isTreelike(aValue)) {
28
+ // Filter the subtree
29
+ return filter(aValue, bValue);
30
+ } else {
31
+ return aValue;
32
+ }
33
+ },
34
+
35
+ async keys() {
36
+ // Use a's keys as the basis
37
+ const aKeys = [...(await a.keys())];
38
+ const bValues = await Promise.all(aKeys.map((key) => b.get(key)));
39
+ // An async tree value in b implies that the a key should have a slash
40
+ const aKeySlashes = aKeys.map((key, index) =>
41
+ trailingSlash.toggle(
42
+ key,
43
+ trailingSlash.has(key) || Tree.isAsyncTree(bValues[index])
44
+ )
45
+ );
46
+ // Remove keys that don't have values in b
47
+ const keys = aKeySlashes.filter((key, index) => bValues[index] ?? false);
48
+ return keys;
49
+ },
50
+ };
51
+ }
@@ -0,0 +1,87 @@
1
+ import { ObjectTree, Tree, merge, trailingSlash } from "@weborigami/async-tree";
2
+
3
+ const globstar = "**";
4
+ const globstarSlash = `${globstar}/`;
5
+
6
+ export default function globKeys(treelike) {
7
+ const globs = Tree.from(treelike, { deep: true });
8
+ return {
9
+ async get(key) {
10
+ if (typeof key !== "string") {
11
+ return undefined;
12
+ }
13
+
14
+ let value = await matchGlobs(globs, key);
15
+ if (Tree.isAsyncTree(value)) {
16
+ value = globKeys(value);
17
+ }
18
+ return value;
19
+ },
20
+
21
+ async keys() {
22
+ return globs.keys();
23
+ },
24
+ };
25
+ }
26
+
27
+ // Convert the glob to a regular expression
28
+ function matchGlob(glob, key) {
29
+ const regexText = glob
30
+ // Escape special regex characters
31
+ .replace(/[+?^${}()|\.\[\]\\]/g, "\\$&")
32
+ // Replace the glob wildcards with regex wildcards
33
+ .replace(/\*/g, ".*")
34
+ .replace(/\?/g, ".");
35
+ const regex = new RegExp(`^${regexText}$`);
36
+ return regex.test(key);
37
+ }
38
+
39
+ async function matchGlobs(globs, key) {
40
+ let globstarGlobs;
41
+
42
+ // Collect all matches
43
+ let matches = [];
44
+ for (let glob of await globs.keys()) {
45
+ if (glob === globstarSlash) {
46
+ // Remember for later
47
+ globstarGlobs = await globs.get(glob);
48
+ if (trailingSlash.has(key)) {
49
+ // A key for a subtree matches the globstar
50
+ matches.push(new ObjectTree({ [globstar]: globstarGlobs }));
51
+ }
52
+ } else if (matchGlob(glob, key)) {
53
+ // Text matches glob, get value
54
+ const globValue = await globs.get(glob);
55
+ if (globValue !== undefined) {
56
+ if (!Tree.isAsyncTree(globValue)) {
57
+ // Found a non-tree match, return immediately
58
+ return globValue;
59
+ }
60
+ // Add to matches
61
+ matches.push(globValue);
62
+ }
63
+ }
64
+ }
65
+
66
+ // If we don't have a match yet, try globstar
67
+ if (matches.length === 0) {
68
+ if (!globstarGlobs) {
69
+ // No matches
70
+ return undefined;
71
+ } else {
72
+ // Try globstar
73
+ const globstarValue = await matchGlobs(globstarGlobs, key);
74
+ if (!Tree.isAsyncTree(globstarValue)) {
75
+ // Found a non-tree match, return immediately
76
+ return globstarValue;
77
+ } else if (trailingSlash.has(key)) {
78
+ // No match but key is for subtree, return globstar tree
79
+ return new ObjectTree({ [globstar]: globstarGlobs });
80
+ }
81
+ }
82
+ }
83
+
84
+ // Merge all matches
85
+ const value = matches.length === 1 ? matches[0] : merge(...matches);
86
+ return value;
87
+ }
@@ -65,37 +65,54 @@ export default function map(treelike, options = {}) {
65
65
 
66
66
  if (keyFn || valueFn) {
67
67
  transformed.get = async (resultKey) => {
68
- // Step 1: Map the result key to the source key.
69
- const sourceKey = (await inverseKeyFn?.(resultKey, tree)) ?? resultKey;
68
+ if (resultKey === undefined) {
69
+ throw new ReferenceError(`map: Cannot get an undefined key.`);
70
+ }
71
+
72
+ // Step 1: Map the result key to the source key
73
+ let sourceKey = await inverseKeyFn?.(resultKey, tree);
70
74
 
71
75
  if (sourceKey === undefined) {
72
- // No source key means no value.
73
- return undefined;
76
+ if (deep && trailingSlash.has(resultKey)) {
77
+ // Special case: deep tree and value is expected to be a subtree
78
+ const sourceValue = await tree.get(resultKey);
79
+ // If we did get a subtree, apply the map to it
80
+ const resultValue = Tree.isAsyncTree(sourceValue)
81
+ ? mapFn(sourceValue)
82
+ : undefined;
83
+ return resultValue;
84
+ } else {
85
+ // No inverseKeyFn, or it returned undefined; use resultKey
86
+ sourceKey = resultKey;
87
+ }
74
88
  }
75
89
 
76
- // Step 2: Get the source value.
90
+ // Regular path: map a single value
91
+
92
+ // Step 2: Get the source value
77
93
  let sourceValue;
78
94
  if (needsSourceValue) {
79
- // Normal case: get the value from the source tree.
95
+ // Normal case: get the value from the source tree
80
96
  sourceValue = await tree.get(sourceKey);
81
- } else if (deep && trailingSlash.has(sourceKey)) {
82
- // Only get the source value if it's expected to be a subtree.
83
- sourceValue = tree;
97
+ if (deep && sourceValue === undefined) {
98
+ // Key might be for a subtree, see if original key exists
99
+ sourceValue = await tree.get(resultKey);
100
+ }
84
101
  }
85
102
 
86
- // Step 3: Map the source value to the result value.
103
+ // Step 3: Map the source value to the result value
87
104
  let resultValue;
88
105
  if (needsSourceValue && sourceValue === undefined) {
89
- // No source value means no result value.
106
+ // No source value means no result value
90
107
  resultValue = undefined;
91
108
  } else if (deep && Tree.isAsyncTree(sourceValue)) {
92
- // Map a subtree.
109
+ // We weren't expecting a subtree but got one; map it
93
110
  resultValue = mapFn(sourceValue);
94
111
  } else if (valueFn) {
95
- // Map a single value.
112
+ // Map a single value
96
113
  resultValue = await valueFn(sourceValue, sourceKey, tree);
97
114
  } else {
98
- // Return source value as is.
115
+ // Return source value as is
99
116
  resultValue = sourceValue;
100
117
  }
101
118
 
@@ -33,7 +33,7 @@ export default async function regExpKeys(treelike) {
33
33
  if (key == null) {
34
34
  // Reject nullish key.
35
35
  throw new ReferenceError(
36
- `${this.constructor.name}: Cannot get a null or undefined key.`
36
+ `regExpKeys: Cannot get a null or undefined key.`
37
37
  );
38
38
  }
39
39
 
@@ -72,14 +72,7 @@ export default async function regExpKeys(treelike) {
72
72
  }
73
73
  } else {
74
74
  // Construct regular expression.
75
- let text = key;
76
- if (!text.startsWith("^")) {
77
- text = "^" + text;
78
- }
79
- if (!text.endsWith("$")) {
80
- text = text + "$";
81
- }
82
- regExp = new RegExp(text);
75
+ regExp = new RegExp(key);
83
76
  }
84
77
  map.set(regExp, value);
85
78
  }
@@ -0,0 +1,13 @@
1
+ import { Tree } from "@weborigami/async-tree";
2
+ import assert from "node:assert";
3
+ import { describe, test } from "node:test";
4
+ import constantTree from "../../src/drivers/constantTree.js";
5
+
6
+ describe("constantTree", () => {
7
+ test("returns a deep tree that returns constant for all keys", async () => {
8
+ const fixture = constantTree(1);
9
+ assert.equal(await fixture.get("a"), 1);
10
+ assert.equal(await fixture.get("b"), 1);
11
+ assert.equal(await Tree.traverse(fixture, "c/", "d/", "e"), 1);
12
+ });
13
+ });
@@ -1,6 +1,6 @@
1
1
  import assert from "node:assert";
2
2
  import { describe, test } from "node:test";
3
- import { DeepObjectTree, ObjectTree, Tree } from "../../src/internal.js";
3
+ import { DeepObjectTree, ObjectTree } from "../../src/internal.js";
4
4
  import cache from "../../src/operations/cache.js";
5
5
 
6
6
  describe("cache", () => {
@@ -36,28 +36,4 @@ describe("cache", () => {
36
36
  const moreCache = await objectCache.get("more");
37
37
  assert.equal(await moreCache.get("d"), 4);
38
38
  });
39
-
40
- test("if a cache filter is supplied, it only caches values whose keys match the filter", async () => {
41
- const objectCache = new ObjectTree({});
42
- const fixture = cache(
43
- Tree.from({
44
- "a.txt": "a",
45
- "b.txt": "b",
46
- }),
47
- objectCache,
48
- Tree.from({
49
- "a.txt": true,
50
- })
51
- );
52
-
53
- // Access some values to populate the cache.
54
- assert.equal(await fixture.get("a.txt"), "a");
55
- assert.equal(await fixture.get("b.txt"), "b");
56
-
57
- // The a.txt value should be cached because it matches the filter.
58
- assert.equal(await objectCache.get("a.txt"), "a");
59
-
60
- // The b.txt value should not be cached because it does not match the filter.
61
- assert.equal(await objectCache.get("b.txt"), undefined);
62
- });
63
39
  });
@@ -0,0 +1,32 @@
1
+ import assert from "node:assert";
2
+ import { describe, test } from "node:test";
3
+ import { Tree } from "../../src/internal.js";
4
+ import filter from "../../src/operations/filter.js";
5
+
6
+ describe("filter", () => {
7
+ test("removes keys and values whose filter values are falsy", async () => {
8
+ const result = filter(
9
+ {
10
+ a: 1,
11
+ b: 2,
12
+ c: {
13
+ d: 3,
14
+ e: 4,
15
+ },
16
+ },
17
+ {
18
+ a: true,
19
+ c: {
20
+ d: true,
21
+ },
22
+ }
23
+ );
24
+ assert.deepEqual(await result.keys(), ["a", "c/"]);
25
+ assert.deepEqual(await Tree.plain(result), {
26
+ a: 1,
27
+ c: {
28
+ d: 3,
29
+ },
30
+ });
31
+ });
32
+ });
@@ -0,0 +1,53 @@
1
+ import { Tree } from "@weborigami/async-tree";
2
+ import assert from "node:assert";
3
+ import { describe, test } from "node:test";
4
+ import globKeys from "../../src/operations/globKeys.js";
5
+
6
+ describe("globKeys", () => {
7
+ test("matches globs", async () => {
8
+ const globTree = globKeys({
9
+ "*.txt": true,
10
+ "*.js": false,
11
+ "*.jsx": true,
12
+ "foo*baz": true,
13
+ });
14
+ assert(await globTree.get("file.txt"));
15
+ assert(!(await globTree.get("script.js")));
16
+ assert(await globTree.get("component.jsx"));
17
+ assert(await globTree.get("foobarbaz"));
18
+ assert(await globTree.get("foobaz"));
19
+ assert(!(await globTree.get("foo")));
20
+ });
21
+
22
+ test("matches nested globs", async () => {
23
+ const globTree = globKeys({
24
+ sub: {
25
+ foo: "bar",
26
+ "*": "default",
27
+ },
28
+ });
29
+ assert.equal(await Tree.traverse(globTree, "sub/", "file"), "default");
30
+ assert.equal(await Tree.traverse(globTree, "sub/", "foo"), "bar");
31
+ });
32
+
33
+ test("supports deep matches with globstar", async () => {
34
+ const globTree = globKeys({
35
+ "**": {
36
+ "*.txt": true, // More specific glob pattern must come
37
+ "*": false,
38
+ },
39
+ sub: {
40
+ "*.md": true,
41
+ },
42
+ "s*": {
43
+ "*.html": true,
44
+ },
45
+ });
46
+ assert.equal(await Tree.traverse(globTree, "a/", "b/", "foo.txt"), true);
47
+ assert.equal(await Tree.traverse(globTree, "c/", "foo"), false);
48
+ assert.equal(await Tree.traverse(globTree, "sub/", "file.md"), true);
49
+ assert.equal(await Tree.traverse(globTree, "sub/", "file.txt"), true);
50
+ assert.equal(await Tree.traverse(globTree, "sub/", "file.html"), true);
51
+ assert.equal(await Tree.traverse(globTree, "sub/", "file"), false);
52
+ });
53
+ });
@@ -7,19 +7,23 @@ describe("regExpKeys", () => {
7
7
  test("matches keys using regular expressions", async () => {
8
8
  const fixture = await regExpKeys(
9
9
  new DeepObjectTree({
10
- a: true,
11
- "b.*": true,
10
+ "^a$": true,
11
+ "^b.*": true,
12
12
  c: {
13
13
  d: true,
14
14
  "e*": true,
15
15
  },
16
+ f: true,
16
17
  })
17
18
  );
18
19
  assert(await Tree.traverse(fixture, "a"));
19
20
  assert(!(await Tree.traverse(fixture, "alice")));
20
21
  assert(await Tree.traverse(fixture, "bob"));
21
22
  assert(await Tree.traverse(fixture, "brenda"));
22
- assert(await Tree.traverse(fixture, "c", "d"));
23
- assert(await Tree.traverse(fixture, "c", "eee"));
23
+ assert(await Tree.traverse(fixture, "c/", "d"));
24
+ assert(await Tree.traverse(fixture, "c/", "eee"));
25
+ assert(await Tree.traverse(fixture, "f"));
26
+ assert(await Tree.traverse(fixture, "stef")); // contains "f"
27
+ assert(!(await Tree.traverse(fixture, "gail")));
24
28
  });
25
29
  });