@weborigami/async-tree 0.6.0 → 0.6.2

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.
Files changed (55) hide show
  1. package/index.ts +13 -1
  2. package/package.json +1 -1
  3. package/shared.js +13 -16
  4. package/src/Tree.js +3 -0
  5. package/src/drivers/AsyncMap.js +30 -3
  6. package/src/drivers/BrowserFileMap.js +30 -23
  7. package/src/drivers/CalendarMap.js +0 -2
  8. package/src/drivers/ConstantMap.js +1 -3
  9. package/src/drivers/ExplorableSiteMap.js +2 -0
  10. package/src/drivers/FileMap.js +50 -57
  11. package/src/drivers/ObjectMap.js +22 -10
  12. package/src/drivers/SiteMap.js +4 -6
  13. package/src/drivers/SyncMap.js +22 -7
  14. package/src/jsonKeys.js +15 -1
  15. package/src/operations/assign.js +7 -12
  16. package/src/operations/cache.js +2 -2
  17. package/src/operations/child.js +35 -0
  18. package/src/operations/from.js +5 -6
  19. package/src/operations/globKeys.js +2 -3
  20. package/src/operations/isMaplike.js +1 -1
  21. package/src/operations/map.js +22 -3
  22. package/src/operations/mapReduce.js +28 -19
  23. package/src/operations/mask.js +34 -18
  24. package/src/operations/paths.js +14 -16
  25. package/src/operations/reduce.js +16 -0
  26. package/src/operations/root.js +2 -2
  27. package/src/operations/set.js +20 -0
  28. package/src/operations/sync.js +2 -9
  29. package/src/operations/traverseOrThrow.js +5 -0
  30. package/src/utilities/castArraylike.js +23 -20
  31. package/src/utilities/toPlainValue.js +6 -8
  32. package/test/browser/index.html +0 -1
  33. package/test/drivers/BrowserFileMap.test.js +21 -23
  34. package/test/drivers/FileMap.test.js +2 -31
  35. package/test/drivers/ObjectMap.test.js +28 -0
  36. package/test/drivers/SyncMap.test.js +19 -5
  37. package/test/jsonKeys.test.js +18 -6
  38. package/test/operations/cache.test.js +11 -8
  39. package/test/operations/cachedKeyFunctions.test.js +8 -6
  40. package/test/operations/child.test.js +34 -0
  41. package/test/operations/deepMerge.test.js +20 -14
  42. package/test/operations/from.test.js +6 -4
  43. package/test/operations/inners.test.js +15 -12
  44. package/test/operations/map.test.js +24 -16
  45. package/test/operations/mapReduce.test.js +14 -12
  46. package/test/operations/mask.test.js +12 -0
  47. package/test/operations/merge.test.js +7 -5
  48. package/test/operations/paths.test.js +20 -27
  49. package/test/operations/regExpKeys.test.js +12 -9
  50. package/test/operations/root.test.js +23 -0
  51. package/test/operations/set.test.js +11 -0
  52. package/test/operations/traverse.test.js +13 -0
  53. package/test/utilities/castArrayLike.test.js +53 -0
  54. package/src/drivers/DeepObjectMap.js +0 -27
  55. package/test/drivers/DeepObjectMap.test.js +0 -36
package/index.ts CHANGED
@@ -43,6 +43,18 @@ export type MapOptions = {
43
43
  value?: ValueKeyFn;
44
44
  };
45
45
 
46
+ export interface SyncTree<MapType> {
47
+ child(key: any): MapType;
48
+ parent: MapType | null;
49
+ trailingSlashKeys: boolean;
50
+ }
51
+
52
+ export interface AsyncTree<MapType> {
53
+ child(key: any): Promise<MapType>;
54
+ parent: MapType | null;
55
+ trailingSlashKeys: boolean;
56
+ }
57
+
46
58
  /**
47
59
  * A packed value is one that can be written to a file via fs.writeFile or into
48
60
  * an HTTP response via response.write, or readily converted to such a form.
@@ -56,7 +68,7 @@ export type PlainObject = {
56
68
  [key: string]: any;
57
69
  };
58
70
 
59
- export type ReduceFn = (values: any[], keys: any[], map: SyncOrAsyncMap) => any | Promise<any>;
71
+ export type ReduceFn = (mapped: Map<any, any>, source: SyncOrAsyncMap) => any | Promise<any>;
60
72
 
61
73
  export type Stringlike = string | HasString;
62
74
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weborigami/async-tree",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "Asynchronous tree drivers based on standard JavaScript classes",
5
5
  "type": "module",
6
6
  "main": "./main.js",
package/shared.js CHANGED
@@ -1,6 +1,5 @@
1
1
  // Exports for both Node.js and browser
2
2
 
3
- import { default as DeepObjectMap } from "./src/drivers/DeepObjectMap.js";
4
3
  import { default as ExplorableSiteMap } from "./src/drivers/ExplorableSiteMap.js";
5
4
  import { default as FileMap } from "./src/drivers/FileMap.js";
6
5
  import { default as FunctionMap } from "./src/drivers/FunctionMap.js";
@@ -14,6 +13,7 @@ export { default as ConstantMap } from "./src/drivers/ConstantMap.js";
14
13
  export { default as SyncMap } from "./src/drivers/SyncMap.js";
15
14
  export * as extension from "./src/extension.js";
16
15
  export * as jsonKeys from "./src/jsonKeys.js";
16
+ export { default as reduce } from "./src/operations/reduce.js";
17
17
  export { default as scope } from "./src/operations/scope.js";
18
18
  export * as symbols from "./src/symbols.js";
19
19
  export * as trailingSlash from "./src/trailingSlash.js";
@@ -35,15 +35,14 @@ export { default as setParent } from "./src/utilities/setParent.js";
35
35
  export { default as toPlainValue } from "./src/utilities/toPlainValue.js";
36
36
  export { default as toString } from "./src/utilities/toString.js";
37
37
 
38
- export {
39
- DeepObjectMap,
40
- ExplorableSiteMap,
41
- FileMap,
42
- FunctionMap,
43
- ObjectMap,
44
- SetMap,
45
- SiteMap,
46
- };
38
+ export { ExplorableSiteMap, FileMap, FunctionMap, ObjectMap, SetMap, SiteMap };
39
+
40
+ export class DeepObjectMap extends ObjectMap {
41
+ constructor(object) {
42
+ super(object, { deep: true });
43
+ console.warn("DeepObjectMap is deprecated. Please use ObjectMap instead.");
44
+ }
45
+ }
47
46
 
48
47
  export class ObjectTree extends ObjectMap {
49
48
  constructor(...args) {
@@ -52,12 +51,10 @@ export class ObjectTree extends ObjectMap {
52
51
  }
53
52
  }
54
53
 
55
- export class DeepObjectTree extends DeepObjectMap {
56
- constructor(...args) {
57
- super(...args);
58
- console.warn(
59
- "DeepObjectTree is deprecated. Please use DeepObjectMap instead."
60
- );
54
+ export class DeepObjectTree extends ObjectMap {
55
+ constructor(object) {
56
+ super(object, { deep: true });
57
+ console.warn("DeepObjectTree is deprecated. Please use ObjectMap instead.");
61
58
  }
62
59
  }
63
60
 
package/src/Tree.js CHANGED
@@ -6,6 +6,7 @@ export { default as addNextPrevious } from "./operations/addNextPrevious.js";
6
6
  export { default as assign } from "./operations/assign.js";
7
7
  export { default as cache } from "./operations/cache.js";
8
8
  export { default as calendar } from "./operations/calendar.js";
9
+ export { default as child } from "./operations/child.js";
9
10
  export { default as clear } from "./operations/clear.js";
10
11
  export { default as constant } from "./operations/constant.js";
11
12
  export { default as deepEntries } from "./operations/deepEntries.js";
@@ -49,10 +50,12 @@ export { default as paginate } from "./operations/paginate.js";
49
50
  export { default as parent } from "./operations/parent.js";
50
51
  export { default as paths } from "./operations/paths.js";
51
52
  export { default as plain } from "./operations/plain.js";
53
+ export { default as reduce } from "./operations/reduce.js";
52
54
  export { default as regExpKeys } from "./operations/regExpKeys.js";
53
55
  export { default as reverse } from "./operations/reverse.js";
54
56
  export { default as root } from "./operations/root.js";
55
57
  export { default as scope } from "./operations/scope.js";
58
+ export { default as set } from "./operations/set.js";
56
59
  export { default as shuffle } from "./operations/shuffle.js";
57
60
  export { default as size } from "./operations/size.js";
58
61
  export { default as sort } from "./operations/sort.js";
@@ -1,5 +1,13 @@
1
+ import isMap from "../operations/isMap.js";
1
2
  import * as trailingSlash from "../trailingSlash.js";
2
3
 
4
+ /**
5
+ * Base class for asynchronous maps. These have the same interface as Map but the methods
6
+ * are asynchronous.
7
+ *
8
+ * @typedef {import("../../index.ts").AsyncTree<AsyncMap>} AsyncTree
9
+ * @implements {AsyncTree}
10
+ */
3
11
  export default class AsyncMap {
4
12
  /** @type {AsyncMap|null} */
5
13
  _parent = null;
@@ -10,15 +18,34 @@ export default class AsyncMap {
10
18
  return this.entries();
11
19
  }
12
20
 
21
+ /**
22
+ * Return the child node for the given key, creating it if necessary.
23
+ */
24
+ async child(key) {
25
+ let result = await this.get(key);
26
+
27
+ // If child is already a map we can use it as is
28
+ if (!isMap(result)) {
29
+ // Create new child node using no-arg constructor
30
+ result = new /** @type {any} */ (this.constructor)();
31
+ await this.set(key, result);
32
+ }
33
+
34
+ result.parent = this;
35
+ return result;
36
+ }
37
+
13
38
  /**
14
39
  * Remove all key/value entries from the map.
15
40
  *
16
41
  * This method invokes the `keys()` and `delete()` methods.
17
42
  */
18
43
  async clear() {
44
+ const promises = [];
19
45
  for await (const key of this.keys()) {
20
- await this.delete(key);
46
+ promises.push(this.delete(key));
21
47
  }
48
+ await Promise.all(promises);
22
49
  }
23
50
 
24
51
  /**
@@ -32,8 +59,6 @@ export default class AsyncMap {
32
59
  throw new Error("delete() not implemented");
33
60
  }
34
61
 
35
- static EMPTY = Symbol("EMPTY");
36
-
37
62
  /**
38
63
  * Returns a new `AsyncIterator` object that contains a two-member array of
39
64
  * [key, value] for each element in the map in insertion order.
@@ -191,6 +216,8 @@ export default class AsyncMap {
191
216
  })();
192
217
  }
193
218
 
219
+ trailingSlashKeys = false;
220
+
194
221
  /**
195
222
  * Returns a new `AsyncIterator` object that contains the values for each
196
223
  * element in the map in insertion order.
@@ -1,6 +1,5 @@
1
1
  import { hiddenFileNames } from "../constants.js";
2
- import assign from "../operations/assign.js";
3
- import isMaplike from "../operations/isMaplike.js";
2
+ import isMap from "../operations/isMap.js";
4
3
  import * as trailingSlash from "../trailingSlash.js";
5
4
  import isStringlike from "../utilities/isStringlike.js";
6
5
  import naturalOrder from "../utilities/naturalOrder.js";
@@ -30,13 +29,35 @@ export default class BrowserFileMap extends AsyncMap {
30
29
  this.directory = directoryHandle;
31
30
  }
32
31
 
32
+ async child(key) {
33
+ const normalized = trailingSlash.remove(key);
34
+ let result = await this.get(normalized);
35
+
36
+ // If child is already a map we can use it as is
37
+ if (!isMap(result)) {
38
+ // Create subfolder
39
+ const directory = await this.getDirectory();
40
+ if (result) {
41
+ // Delete existing file with same name
42
+ await directory.removeEntry(normalized);
43
+ }
44
+ const subfolderHandle = await directory.getDirectoryHandle(normalized, {
45
+ create: true,
46
+ });
47
+ result = Reflect.construct(this.constructor, [subfolderHandle]);
48
+ setParent(result, this);
49
+ }
50
+
51
+ return result;
52
+ }
53
+
33
54
  async delete(key) {
34
- const baseKey = trailingSlash.remove(key);
55
+ const normalized = trailingSlash.remove(key);
35
56
  const directory = await this.getDirectory();
36
57
 
37
58
  // Delete file.
38
59
  try {
39
- await directory.removeEntry(baseKey);
60
+ await directory.removeEntry(normalized);
40
61
  } catch (error) {
41
62
  // If the file didn't exist, ignore the error.
42
63
  if (error instanceof DOMException && error.name === "NotFoundError") {
@@ -111,9 +132,9 @@ export default class BrowserFileMap extends AsyncMap {
111
132
  // @ts-ignore
112
133
  for await (const entryKey of directory.keys()) {
113
134
  // Check if the entry is a subfolder
114
- const baseKey = trailingSlash.remove(entryKey);
135
+ const normalized = trailingSlash.remove(entryKey);
115
136
  const subfolderHandle = await directory
116
- .getDirectoryHandle(baseKey)
137
+ .getDirectoryHandle(normalized)
117
138
  .catch(() => null);
118
139
  const isSubfolder = subfolderHandle !== null;
119
140
 
@@ -129,7 +150,7 @@ export default class BrowserFileMap extends AsyncMap {
129
150
  }
130
151
 
131
152
  async set(key, value) {
132
- const baseKey = trailingSlash.remove(key);
153
+ const normalized = trailingSlash.remove(key);
133
154
  const directory = await this.getDirectory();
134
155
 
135
156
  // Treat null value as empty string; will create an empty file.
@@ -152,24 +173,12 @@ export default class BrowserFileMap extends AsyncMap {
152
173
 
153
174
  if (isWriteable) {
154
175
  // Write file.
155
- const fileHandle = await directory.getFileHandle(baseKey, {
176
+ const fileHandle = await directory.getFileHandle(normalized, {
156
177
  create: true,
157
178
  });
158
179
  const writable = await fileHandle.createWritable();
159
180
  await writable.write(value);
160
181
  await writable.close();
161
- } else if (value === /** @type {any} */ (this.constructor).EMPTY) {
162
- // Create empty subtree.
163
- await directory.getDirectoryHandle(baseKey, {
164
- create: true,
165
- });
166
- } else if (isMaplike(value)) {
167
- // Treat value as a tree and write it out as a subdirectory.
168
- const subdirectory = await directory.getDirectoryHandle(baseKey, {
169
- create: true,
170
- });
171
- const destTree = Reflect.construct(this.constructor, [subdirectory]);
172
- await assign(destTree, value);
173
182
  } else {
174
183
  const typeName = value?.constructor?.name ?? "unknown";
175
184
  throw new TypeError(`Cannot write a value of type ${typeName} as ${key}`);
@@ -178,7 +187,5 @@ export default class BrowserFileMap extends AsyncMap {
178
187
  return this;
179
188
  }
180
189
 
181
- get trailingSlashKeys() {
182
- return true;
183
- }
190
+ trailingSlashKeys = true;
184
191
  }
@@ -80,8 +80,6 @@ export default class CalendarMap extends SyncMap {
80
80
  (_, i) => this.start.year + i
81
81
  )[Symbol.iterator]();
82
82
  }
83
-
84
- trailingSlashKeys = false;
85
83
  }
86
84
 
87
85
  function dateParts(date) {
@@ -24,7 +24,5 @@ export default class ConstantTree extends SyncMap {
24
24
  return [][Symbol.iterator]();
25
25
  }
26
26
 
27
- get trailingSlashKeys() {
28
- return true;
29
- }
27
+ trailingSlashKeys = true;
30
28
  }
@@ -53,4 +53,6 @@ export default class ExplorableSiteMap extends SiteMap {
53
53
 
54
54
  return super.processResponse(response);
55
55
  }
56
+
57
+ trailingSlashKeys = true;
56
58
  }
@@ -2,8 +2,6 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { hiddenFileNames } from "../constants.js";
5
- import from from "../operations/from.js";
6
- import isMaplike from "../operations/isMaplike.js";
7
5
  import * as trailingSlash from "../trailingSlash.js";
8
6
  import isPacked from "../utilities/isPacked.js";
9
7
  import isStringlike from "../utilities/isStringlike.js";
@@ -39,6 +37,26 @@ export default class FileMap extends SyncMap {
39
37
  : path.resolve(process.cwd(), location);
40
38
  }
41
39
 
40
+ // Return the (possibly new) subdirectory with the given key.
41
+ child(key) {
42
+ const stringKey = key != null ? String(key) : "";
43
+ const baseKey = trailingSlash.remove(stringKey);
44
+ const destPath = path.resolve(this.dirname, baseKey);
45
+ const destTree = Reflect.construct(this.constructor, [destPath]);
46
+
47
+ const stats = getStats(destPath);
48
+ if (stats === null || !stats.isDirectory()) {
49
+ if (stats !== null) {
50
+ // File with the same name exists; delete it.
51
+ fs.rmSync(destPath);
52
+ }
53
+ // Ensure the directory exists.
54
+ fs.mkdirSync(destPath, { recursive: true });
55
+ }
56
+
57
+ return destTree;
58
+ }
59
+
42
60
  delete(key) {
43
61
  if (key === "" || key == null) {
44
62
  // Can't have a file with no name or a nullish name
@@ -76,14 +94,9 @@ export default class FileMap extends SyncMap {
76
94
  key = trailingSlash.remove(key); // normalize key
77
95
  const filePath = path.resolve(this.dirname, key);
78
96
 
79
- let stats;
80
- try {
81
- stats = fs.statSync(filePath);
82
- } catch (/** @type {any} */ error) {
83
- if (error.code === "ENOENT" /* File not found */) {
84
- return undefined;
85
- }
86
- throw error;
97
+ const stats = getStats(filePath);
98
+ if (stats === null) {
99
+ return undefined; // File or directory doesn't exist
87
100
  }
88
101
 
89
102
  let value;
@@ -140,13 +153,19 @@ export default class FileMap extends SyncMap {
140
153
  set(key, value) {
141
154
  // Where are we going to write this value?
142
155
  const stringKey = key != null ? String(key) : "";
143
- const baseKey = trailingSlash.remove(stringKey);
144
- const destPath = path.resolve(this.dirname, baseKey);
156
+ const normalized = trailingSlash.remove(stringKey);
157
+ const destPath = path.resolve(this.dirname, normalized);
145
158
 
146
159
  // Ensure this directory exists.
147
160
  const dirname = path.dirname(destPath);
148
161
  fs.mkdirSync(dirname, { recursive: true });
149
162
 
163
+ if (value === undefined) {
164
+ // Special case: undefined value is equivalent to delete()
165
+ this.delete(normalized);
166
+ return this;
167
+ }
168
+
150
169
  if (typeof value === "function") {
151
170
  // Invoke function; write out the result.
152
171
  value = value();
@@ -179,10 +198,6 @@ export default class FileMap extends SyncMap {
179
198
 
180
199
  if (packed) {
181
200
  writeFile(value, destPath);
182
- } else if (value === /** @type {any} */ (this.constructor).EMPTY) {
183
- clearDirectory(destPath, this);
184
- } else if (isMaplike(value)) {
185
- writeDirectory(value, destPath, this);
186
201
  } else {
187
202
  const typeName = value?.constructor?.name ?? "unknown";
188
203
  throw new TypeError(
@@ -193,53 +208,31 @@ export default class FileMap extends SyncMap {
193
208
  return this;
194
209
  }
195
210
 
196
- get trailingSlashKeys() {
197
- return true;
198
- }
199
- }
200
-
201
- // Create the indicated directory.
202
- function clearDirectory(destPath, parent) {
203
- const destTree = Reflect.construct(parent.constructor, [destPath]);
204
-
205
- // Ensure the directory exists.
206
- fs.mkdirSync(destPath, { recursive: true });
207
-
208
- // Clear any existing files
209
- destTree.clear();
210
-
211
- return destTree;
211
+ trailingSlashKeys = true;
212
212
  }
213
213
 
214
- /**
215
- * Treat value as a subtree and write it out as a subdirectory.
216
- *
217
- * @param {import("../../index.ts").Maplike} value
218
- */
219
- function writeDirectory(value, destPath, parent) {
220
- // Since value is Maplike, result will be a Map
221
- /** @type {Map} */
222
- // @ts-ignore
223
- const valueMap = from(value);
224
- const destTree = clearDirectory(destPath, parent);
225
-
226
- // Write out the subtree.
227
- for (const key of valueMap.keys()) {
228
- const childValue = valueMap.get(key);
229
- destTree.set(key, childValue);
214
+ // Return stats for the path, or null if it doesn't exist.
215
+ function getStats(filePath) {
216
+ let stats;
217
+ try {
218
+ stats = fs.statSync(filePath);
219
+ } catch (/** @type {any} */ error) {
220
+ if (error.code === "ENOENT" /* File not found */) {
221
+ return null;
222
+ }
223
+ throw error;
230
224
  }
225
+ return stats;
231
226
  }
232
227
 
233
228
  // Write a value to a file.
234
229
  function writeFile(value, destPath) {
235
- // Write out the value as the contents of a file.
236
- try {
237
- fs.writeFileSync(destPath, value);
238
- } catch (/** @type {any} */ error) {
239
- if (error.code === "EISDIR" /* Is a directory */) {
240
- throw new Error(
241
- `Tried to overwrite a directory with a single file: ${destPath}`
242
- );
243
- }
230
+ // If path exists and it's a directory, delete the directory first.
231
+ const stats = getStats(destPath);
232
+ if (stats !== null && stats.isDirectory()) {
233
+ fs.rmSync(destPath, { recursive: true });
244
234
  }
235
+
236
+ // Write out the value as the contents of a file.
237
+ fs.writeFileSync(destPath, value);
245
238
  }
@@ -1,10 +1,20 @@
1
1
  import * as symbols from "../symbols.js";
2
2
  import * as trailingSlash from "../trailingSlash.js";
3
+ import isPlainObject from "../utilities/isPlainObject.js";
3
4
  import setParent from "../utilities/setParent.js";
4
5
  import SyncMap from "./SyncMap.js";
5
6
 
7
+ /**
8
+ * Map wrapper for a JavaScript object or array
9
+ */
6
10
  export default class ObjectMap extends SyncMap {
7
- constructor(object = {}) {
11
+ /**
12
+ * Wrap the given object in a `Map`.
13
+ *
14
+ * @param {any} object
15
+ * @param {{ deep?: boolean }} [options]
16
+ */
17
+ constructor(object = {}, options = {}) {
8
18
  super();
9
19
  // Note: we use `typeof` here instead of `instanceof Object` to allow for
10
20
  // objects such as Node's `Module` class for representing an ES module.
@@ -15,6 +25,7 @@ export default class ObjectMap extends SyncMap {
15
25
  }
16
26
  this.object = object;
17
27
  this.parent = object[symbols.parent] ?? null;
28
+ this.deep = options.deep === true;
18
29
  }
19
30
 
20
31
  delete(key) {
@@ -51,12 +62,20 @@ export default class ObjectMap extends SyncMap {
51
62
  value = value.bind(this.object);
52
63
  }
53
64
 
65
+ if (this.deep && (value instanceof Array || isPlainObject(value))) {
66
+ // Construct submap for sub-objects and sub-arrays
67
+ value = Reflect.construct(this.constructor, [value, { deep: true }]);
68
+ }
69
+
54
70
  return value;
55
71
  }
56
72
 
57
73
  /** @returns {boolean} */
58
74
  isSubtree(value) {
59
- return value instanceof Map;
75
+ if (value instanceof Map) {
76
+ return true;
77
+ }
78
+ return this.deep && (value instanceof Array || isPlainObject(value));
60
79
  }
61
80
 
62
81
  keys() {
@@ -109,20 +128,13 @@ export default class ObjectMap extends SyncMap {
109
128
  delete this.object[existingKey];
110
129
  }
111
130
 
112
- if (value === /** @type {any} */ (this.constructor).EMPTY) {
113
- // Create empty subtree
114
- value = Reflect.construct(this.constructor, []);
115
- }
116
-
117
131
  // Set the value for the key.
118
132
  this.object[key] = value;
119
133
 
120
134
  return this;
121
135
  }
122
136
 
123
- get trailingSlashKeys() {
124
- return true;
125
- }
137
+ trailingSlashKeys = true;
126
138
  }
127
139
 
128
140
  function findExistingKey(object, key) {
@@ -34,9 +34,9 @@ export default class SiteMap extends AsyncMap {
34
34
  );
35
35
  }
36
36
 
37
- // A key with a trailing slash and no extension is for a folder; return a
38
- // subtree without making a network request.
39
- if (trailingSlash.has(key) && !key.includes(".")) {
37
+ // A key with a trailing slash is for a folder; return a subtree without
38
+ // making a network request.
39
+ if (trailingSlash.has(key)) {
40
40
  const href = new URL(key, this.href).href;
41
41
  const value = Reflect.construct(this.constructor, [href]);
42
42
  setParent(value, this);
@@ -112,9 +112,7 @@ export default class SiteMap extends AsyncMap {
112
112
  }
113
113
  }
114
114
 
115
- get trailingSlashKeys() {
116
- return true;
117
- }
115
+ trailingSlashKeys = true;
118
116
 
119
117
  get url() {
120
118
  return new URL(this.href);
@@ -17,6 +17,9 @@ const previewSymbol = Symbol("preview");
17
17
  * also indicate children subtrees using the trailing slash convention: a key
18
18
  * for a subtree may optionally end with a slash. The get() and has() methods
19
19
  * support optional trailing slashes on keys.
20
+ *
21
+ * @typedef {import("../../index.ts").SyncTree<SyncMap>} SyncTree
22
+ * @implements {SyncTree}
20
23
  */
21
24
  export default class SyncMap extends Map {
22
25
  _initialized = false;
@@ -36,6 +39,23 @@ export default class SyncMap extends Map {
36
39
  this._self = this;
37
40
  }
38
41
 
42
+ /**
43
+ * Return the child node for the given key, creating it if necessary.
44
+ */
45
+ child(key) {
46
+ let result = this.get(key);
47
+
48
+ // If child is already a map we can use it as is
49
+ if (!(result instanceof Map)) {
50
+ // Create new child node using no-arg constructor
51
+ result = new /** @type {any} */ (this.constructor)();
52
+ this.set(key, result);
53
+ }
54
+
55
+ result.parent = this;
56
+ return result;
57
+ }
58
+
39
59
  /**
40
60
  * Removes all key/value entries from the map.
41
61
  *
@@ -68,8 +88,6 @@ export default class SyncMap extends Map {
68
88
  return super.delete.call(this._self, key);
69
89
  }
70
90
 
71
- static EMPTY = Symbol("EMPTY");
72
-
73
91
  /**
74
92
  * Returns a new `Iterator` object that contains a two-member array of [key,
75
93
  * value] for each element in the map in insertion order.
@@ -191,11 +209,6 @@ export default class SyncMap extends Map {
191
209
  // necessary to let the constructor call `super()`.
192
210
  const target = this._self ?? this;
193
211
 
194
- // Support EMPTY symbol to create empty subtrees
195
- if (value === /** @type {any} */ (this.constructor).EMPTY) {
196
- value = Reflect.construct(this.constructor, []);
197
- }
198
-
199
212
  return super.set.call(target, key, value);
200
213
  }
201
214
 
@@ -219,6 +232,8 @@ export default class SyncMap extends Map {
219
232
  return this.entries();
220
233
  }
221
234
 
235
+ trailingSlashKeys = false;
236
+
222
237
  /**
223
238
  * Returns a new `Iterator` object that contains the values for each element
224
239
  * in the map in insertion order.
package/src/jsonKeys.js CHANGED
@@ -1,5 +1,8 @@
1
+ import entries from "./operations/entries.js";
1
2
  import from from "./operations/from.js";
3
+ import isMap from "./operations/isMap.js";
2
4
  import keys from "./operations/keys.js";
5
+ import * as trailingSlash from "./trailingSlash.js";
3
6
 
4
7
  /**
5
8
  * Given a tree node, return a JSON string that can be written to a .keys.json
@@ -14,7 +17,18 @@ import keys from "./operations/keys.js";
14
17
  */
15
18
  export async function stringify(maplike) {
16
19
  const tree = from(maplike);
17
- let treeKeys = await keys(tree);
20
+
21
+ let treeKeys;
22
+ if (/** @type {any} */ (tree).trailingSlashKeys) {
23
+ treeKeys = await keys(tree);
24
+ } else {
25
+ // Use entries() to determine which keys are subtrees.
26
+ const treeEntries = await entries(tree);
27
+ treeKeys = treeEntries.map(([key, value]) =>
28
+ trailingSlash.toggle(key, isMap(value))
29
+ );
30
+ }
31
+
18
32
  // Skip the key `.keys.json` if present.
19
33
  treeKeys = treeKeys.filter((key) => key !== ".keys.json");
20
34
  const json = JSON.stringify(treeKeys);