@weborigami/async-tree 0.5.8 → 0.6.1

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 (182) hide show
  1. package/browser.js +1 -1
  2. package/index.ts +43 -35
  3. package/main.js +1 -2
  4. package/package.json +4 -7
  5. package/shared.js +74 -12
  6. package/src/Tree.js +11 -2
  7. package/src/drivers/AsyncMap.js +237 -0
  8. package/src/drivers/{BrowserFileTree.js → BrowserFileMap.js} +54 -38
  9. package/src/drivers/{calendarTree.js → CalendarMap.js} +80 -63
  10. package/src/drivers/ConstantMap.js +28 -0
  11. package/src/drivers/{ExplorableSiteTree.js → ExplorableSiteMap.js} +7 -7
  12. package/src/drivers/FileMap.js +238 -0
  13. package/src/drivers/{FunctionTree.js → FunctionMap.js} +19 -22
  14. package/src/drivers/ObjectMap.js +151 -0
  15. package/src/drivers/SetMap.js +13 -0
  16. package/src/drivers/{SiteTree.js → SiteMap.js} +17 -20
  17. package/src/drivers/SyncMap.js +260 -0
  18. package/src/jsonKeys.d.ts +2 -2
  19. package/src/jsonKeys.js +20 -5
  20. package/src/operations/addNextPrevious.js +35 -36
  21. package/src/operations/assign.js +27 -23
  22. package/src/operations/cache.js +30 -36
  23. package/src/operations/cachedKeyFunctions.js +1 -1
  24. package/src/operations/calendar.js +5 -0
  25. package/src/operations/child.js +35 -0
  26. package/src/operations/clear.js +13 -12
  27. package/src/operations/constant.js +5 -0
  28. package/src/operations/deepEntries.js +23 -0
  29. package/src/operations/deepMap.js +9 -9
  30. package/src/operations/deepMerge.js +36 -25
  31. package/src/operations/deepReverse.js +23 -16
  32. package/src/operations/deepTake.js +7 -7
  33. package/src/operations/deepText.js +4 -4
  34. package/src/operations/deepValues.js +3 -6
  35. package/src/operations/deepValuesIterator.js +11 -11
  36. package/src/operations/delete.js +8 -12
  37. package/src/operations/entries.js +17 -10
  38. package/src/operations/filter.js +9 -7
  39. package/src/operations/first.js +12 -10
  40. package/src/operations/forEach.js +10 -13
  41. package/src/operations/from.js +30 -39
  42. package/src/operations/globKeys.js +22 -17
  43. package/src/operations/group.js +2 -2
  44. package/src/operations/groupBy.js +24 -22
  45. package/src/operations/has.js +7 -9
  46. package/src/operations/indent.js +2 -2
  47. package/src/operations/inners.js +19 -15
  48. package/src/operations/invokeFunctions.js +22 -10
  49. package/src/operations/isAsyncMutableTree.js +5 -12
  50. package/src/operations/isAsyncTree.js +5 -20
  51. package/src/operations/isMap.js +39 -0
  52. package/src/operations/isMaplike.js +34 -0
  53. package/src/operations/isReadOnlyMap.js +14 -0
  54. package/src/operations/isTraversable.js +3 -3
  55. package/src/operations/isTreelike.js +5 -30
  56. package/src/operations/json.js +4 -12
  57. package/src/operations/keys.js +17 -8
  58. package/src/operations/length.js +9 -8
  59. package/src/operations/map.js +28 -30
  60. package/src/operations/mapExtension.js +20 -16
  61. package/src/operations/mapReduce.js +38 -24
  62. package/src/operations/mask.js +54 -29
  63. package/src/operations/match.js +13 -9
  64. package/src/operations/merge.js +43 -35
  65. package/src/operations/paginate.js +26 -18
  66. package/src/operations/parent.js +7 -7
  67. package/src/operations/paths.js +20 -22
  68. package/src/operations/plain.js +6 -6
  69. package/src/operations/reduce.js +16 -0
  70. package/src/operations/regExpKeys.js +21 -12
  71. package/src/operations/reverse.js +21 -15
  72. package/src/operations/root.js +6 -5
  73. package/src/operations/scope.js +31 -26
  74. package/src/operations/set.js +20 -0
  75. package/src/operations/shuffle.js +23 -16
  76. package/src/operations/size.js +13 -0
  77. package/src/operations/sort.js +55 -40
  78. package/src/operations/sync.js +14 -0
  79. package/src/operations/take.js +23 -11
  80. package/src/operations/text.js +4 -4
  81. package/src/operations/toFunction.js +7 -7
  82. package/src/operations/traverse.js +4 -4
  83. package/src/operations/traverseOrThrow.js +18 -9
  84. package/src/operations/traversePath.js +2 -2
  85. package/src/operations/values.js +18 -9
  86. package/src/operations/withKeys.js +22 -16
  87. package/src/symbols.js +1 -0
  88. package/src/utilities/castArraylike.js +24 -13
  89. package/src/utilities/getMapArgument.js +38 -0
  90. package/src/utilities/getParent.js +2 -2
  91. package/src/utilities/isStringlike.js +7 -5
  92. package/src/utilities/setParent.js +7 -7
  93. package/src/utilities/toFunction.js +2 -2
  94. package/src/utilities/toPlainValue.js +21 -19
  95. package/test/SampleAsyncMap.js +34 -0
  96. package/test/browser/assert.js +20 -0
  97. package/test/browser/index.html +53 -21
  98. package/test/drivers/AsyncMap.test.js +119 -0
  99. package/test/drivers/{BrowserFileTree.test.js → BrowserFileMap.test.js} +50 -33
  100. package/test/drivers/{calendarTree.test.js → CalendarMap.test.js} +17 -19
  101. package/test/drivers/ConstantMap.test.js +15 -0
  102. package/test/drivers/{ExplorableSiteTree.test.js → ExplorableSiteMap.test.js} +29 -14
  103. package/test/drivers/FileMap.test.js +156 -0
  104. package/test/drivers/FunctionMap.test.js +56 -0
  105. package/test/drivers/ObjectMap.test.js +194 -0
  106. package/test/drivers/SetMap.test.js +35 -0
  107. package/test/drivers/{SiteTree.test.js → SiteMap.test.js} +14 -10
  108. package/test/drivers/SyncMap.test.js +335 -0
  109. package/test/jsonKeys.test.js +18 -6
  110. package/test/operations/addNextPrevious.test.js +3 -2
  111. package/test/operations/assign.test.js +30 -35
  112. package/test/operations/cache.test.js +17 -12
  113. package/test/operations/cachedKeyFunctions.test.js +12 -9
  114. package/test/operations/child.test.js +34 -0
  115. package/test/operations/clear.test.js +6 -27
  116. package/test/operations/deepEntries.test.js +32 -0
  117. package/test/operations/deepMerge.test.js +23 -16
  118. package/test/operations/deepReverse.test.js +2 -2
  119. package/test/operations/deepTake.test.js +2 -2
  120. package/test/operations/deepText.test.js +4 -4
  121. package/test/operations/deepValuesIterator.test.js +2 -2
  122. package/test/operations/delete.test.js +2 -2
  123. package/test/operations/extensionKeyFunctions.test.js +6 -5
  124. package/test/operations/filter.test.js +3 -3
  125. package/test/operations/from.test.js +25 -31
  126. package/test/operations/globKeys.test.js +9 -9
  127. package/test/operations/groupBy.test.js +6 -5
  128. package/test/operations/inners.test.js +17 -14
  129. package/test/operations/invokeFunctions.test.js +2 -2
  130. package/test/operations/isMap.test.js +15 -0
  131. package/test/operations/isMaplike.test.js +15 -0
  132. package/test/operations/json.test.js +2 -2
  133. package/test/operations/keys.test.js +16 -3
  134. package/test/operations/map.test.js +40 -30
  135. package/test/operations/mapExtension.test.js +6 -6
  136. package/test/operations/mapReduce.test.js +14 -12
  137. package/test/operations/mask.test.js +16 -3
  138. package/test/operations/match.test.js +2 -2
  139. package/test/operations/merge.test.js +20 -14
  140. package/test/operations/paginate.test.js +5 -5
  141. package/test/operations/parent.test.js +3 -3
  142. package/test/operations/paths.test.js +20 -27
  143. package/test/operations/plain.test.js +8 -8
  144. package/test/operations/regExpKeys.test.js +22 -18
  145. package/test/operations/reverse.test.js +4 -3
  146. package/test/operations/scope.test.js +6 -5
  147. package/test/operations/set.test.js +11 -0
  148. package/test/operations/shuffle.test.js +3 -2
  149. package/test/operations/sort.test.js +7 -10
  150. package/test/operations/sync.test.js +43 -0
  151. package/test/operations/take.test.js +2 -2
  152. package/test/operations/toFunction.test.js +2 -2
  153. package/test/operations/traverse.test.js +17 -5
  154. package/test/operations/withKeys.test.js +2 -2
  155. package/test/utilities/castArrayLike.test.js +53 -0
  156. package/test/utilities/setParent.test.js +6 -6
  157. package/test/utilities/toFunction.test.js +2 -2
  158. package/test/utilities/toPlainValue.test.js +51 -12
  159. package/src/drivers/DeepMapTree.js +0 -23
  160. package/src/drivers/DeepObjectTree.js +0 -18
  161. package/src/drivers/DeferredTree.js +0 -81
  162. package/src/drivers/FileTree.js +0 -276
  163. package/src/drivers/MapTree.js +0 -70
  164. package/src/drivers/ObjectTree.js +0 -158
  165. package/src/drivers/SetTree.js +0 -34
  166. package/src/drivers/constantTree.js +0 -19
  167. package/src/drivers/limitConcurrency.js +0 -63
  168. package/src/internal.js +0 -16
  169. package/src/utilities/getTreeArgument.js +0 -43
  170. package/test/drivers/DeepMapTree.test.js +0 -17
  171. package/test/drivers/DeepObjectTree.test.js +0 -35
  172. package/test/drivers/DeferredTree.test.js +0 -22
  173. package/test/drivers/FileTree.test.js +0 -192
  174. package/test/drivers/FunctionTree.test.js +0 -46
  175. package/test/drivers/MapTree.test.js +0 -59
  176. package/test/drivers/ObjectTree.test.js +0 -163
  177. package/test/drivers/SetTree.test.js +0 -44
  178. package/test/drivers/constantTree.test.js +0 -13
  179. package/test/drivers/limitConcurrency.test.js +0 -41
  180. package/test/operations/isAsyncMutableTree.test.js +0 -17
  181. package/test/operations/isAsyncTree.test.js +0 -26
  182. package/test/operations/isTreelike.test.js +0 -13
@@ -1,24 +1,21 @@
1
1
  import { hiddenFileNames } from "../constants.js";
2
- import assign from "../operations/assign.js";
3
- import isTreelike from "../operations/isTreelike.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";
7
6
  import setParent from "../utilities/setParent.js";
7
+ import AsyncMap from "./AsyncMap.js";
8
8
 
9
9
  const TypedArray = Object.getPrototypeOf(Uint8Array);
10
10
 
11
11
  /**
12
- * A tree of files backed by a browser-hosted file system such as the standard
12
+ * A map of files backed by a browser-hosted file system such as the standard
13
13
  * Origin Private File System or the (as of October 2023) experimental File
14
14
  * System Access API.
15
- *
16
- * @typedef {import("@weborigami/types").AsyncMutableTree} AsyncMutableTree
17
- * @implements {AsyncMutableTree}
18
15
  */
19
- export default class BrowserFileTree {
16
+ export default class BrowserFileMap extends AsyncMap {
20
17
  /**
21
- * Construct a tree of files backed by a browser-hosted file system.
18
+ * Construct a map of files backed by a browser-hosted file system.
22
19
  *
23
20
  * The directory handle can be obtained via any of the [methods that return a
24
21
  * FileSystemDirectoryHandle](https://developer.mozilla.org/en-US/docs/Web/API/FileSystemDirectoryHandle).
@@ -28,11 +25,50 @@ export default class BrowserFileTree {
28
25
  * @param {FileSystemDirectoryHandle} [directoryHandle]
29
26
  */
30
27
  constructor(directoryHandle) {
31
- /** @type {FileSystemDirectoryHandle}
32
- * @ts-ignore */
28
+ super();
33
29
  this.directory = directoryHandle;
34
30
  }
35
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
+
54
+ async delete(key) {
55
+ const normalized = trailingSlash.remove(key);
56
+ const directory = await this.getDirectory();
57
+
58
+ // Delete file.
59
+ try {
60
+ await directory.removeEntry(normalized);
61
+ } catch (error) {
62
+ // If the file didn't exist, ignore the error.
63
+ if (error instanceof DOMException && error.name === "NotFoundError") {
64
+ return false;
65
+ }
66
+ throw error;
67
+ }
68
+
69
+ return true;
70
+ }
71
+
36
72
  async get(key) {
37
73
  if (key == null) {
38
74
  // Reject nullish key.
@@ -90,15 +126,15 @@ export default class BrowserFileTree {
90
126
  return this.directory;
91
127
  }
92
128
 
93
- async keys() {
129
+ async *keys() {
94
130
  const directory = await this.getDirectory();
95
131
  let keys = [];
96
132
  // @ts-ignore
97
133
  for await (const entryKey of directory.keys()) {
98
134
  // Check if the entry is a subfolder
99
- const baseKey = trailingSlash.remove(entryKey);
135
+ const normalized = trailingSlash.remove(entryKey);
100
136
  const subfolderHandle = await directory
101
- .getDirectoryHandle(baseKey)
137
+ .getDirectoryHandle(normalized)
102
138
  .catch(() => null);
103
139
  const isSubfolder = subfolderHandle !== null;
104
140
 
@@ -110,28 +146,13 @@ export default class BrowserFileTree {
110
146
  keys = keys.filter((key) => !hiddenFileNames.includes(key));
111
147
  keys.sort(naturalOrder);
112
148
 
113
- return keys;
149
+ yield* keys;
114
150
  }
115
151
 
116
152
  async set(key, value) {
117
- const baseKey = trailingSlash.remove(key);
153
+ const normalized = trailingSlash.remove(key);
118
154
  const directory = await this.getDirectory();
119
155
 
120
- if (value === undefined) {
121
- // Delete file.
122
- try {
123
- await directory.removeEntry(baseKey);
124
- } catch (error) {
125
- // If the file didn't exist, ignore the error.
126
- if (
127
- !(error instanceof DOMException && error.name === "NotFoundError")
128
- ) {
129
- throw error;
130
- }
131
- }
132
- return this;
133
- }
134
-
135
156
  // Treat null value as empty string; will create an empty file.
136
157
  if (value === null) {
137
158
  value = "";
@@ -152,19 +173,12 @@ export default class BrowserFileTree {
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 (isTreelike(value)) {
162
- // Treat value as a tree and write it out as a subdirectory.
163
- const subdirectory = await directory.getDirectoryHandle(baseKey, {
164
- create: true,
165
- });
166
- const destTree = Reflect.construct(this.constructor, [subdirectory]);
167
- await assign(destTree, value);
168
182
  } else {
169
183
  const typeName = value?.constructor?.name ?? "unknown";
170
184
  throw new TypeError(`Cannot write a value of type ${typeName} as ${key}`);
@@ -172,4 +186,6 @@ export default class BrowserFileTree {
172
186
 
173
187
  return this;
174
188
  }
189
+
190
+ trailingSlashKeys = true;
175
191
  }
@@ -1,4 +1,5 @@
1
1
  import * as trailingSlash from "../trailingSlash.js";
2
+ import SyncMap from "./SyncMap.js";
2
3
 
3
4
  /**
4
5
  * Return a tree of years, months, and days from a start date to an end date.
@@ -14,41 +15,71 @@ import * as trailingSlash from "../trailingSlash.js";
14
15
  *
15
16
  * @typedef {string|undefined} CalendarOptionsDate
16
17
  * @typedef {( year: string, month: string, day: string ) => any} CalendarOptionsFn
18
+ * @typedef {{ year: number, month: number, day: number}} CalendarDateParts}
19
+ *
17
20
  * @param {{ end?: CalendarOptionsDate, start?: CalendarOptionsDate, value: CalendarOptionsFn }} options
18
21
  */
19
- export default function calendarTree(options) {
20
- const start = dateParts(options.start);
21
- const end = dateParts(options.end);
22
- const valueFn = options.value;
23
-
24
- // Fill in the missing parts of the start and end dates.
25
- const today = new Date();
26
-
27
- if (start.day === undefined) {
28
- start.day = start.year ? 1 : today.getDate();
29
- }
30
- if (start.month === undefined) {
31
- start.month = start.year ? 1 : today.getMonth() + 1;
32
- }
33
- if (start.year === undefined) {
34
- start.year = today.getFullYear();
22
+ export default class CalendarMap extends SyncMap {
23
+ constructor(options = {}) {
24
+ super();
25
+
26
+ /** @type {CalendarDateParts} */
27
+ // @ts-ignore
28
+ const start = dateParts(options.start);
29
+ /** @type {CalendarDateParts} */
30
+ // @ts-ignore
31
+ const end = dateParts(options.end);
32
+ const valueFn = options.value;
33
+
34
+ // Fill in the missing parts of the start and end dates.
35
+ const today = new Date();
36
+
37
+ if (start.day === undefined) {
38
+ start.day = start.year ? 1 : today.getDate();
39
+ }
40
+ if (start.month === undefined) {
41
+ start.month = start.year ? 1 : today.getMonth() + 1;
42
+ }
43
+ if (start.year === undefined) {
44
+ start.year = today.getFullYear();
45
+ }
46
+
47
+ if (end.day === undefined) {
48
+ end.day = end.month
49
+ ? daysInMonth(end.year, end.month)
50
+ : end.year
51
+ ? 31 // Last day of December
52
+ : today.getDate();
53
+ }
54
+ if (end.month === undefined) {
55
+ end.month = end.year ? 12 : today.getMonth() + 1;
56
+ }
57
+ if (end.year === undefined) {
58
+ end.year = today.getFullYear();
59
+ }
60
+
61
+ this.start = start;
62
+ this.end = end;
63
+ this.valueFn = valueFn ?? defaultValueFn;
35
64
  }
36
65
 
37
- if (end.day === undefined) {
38
- end.day = end.month
39
- ? daysInMonth(end.year, end.month)
40
- : end.year
41
- ? 31 // Last day of December
42
- : today.getDate();
43
- }
44
- if (end.month === undefined) {
45
- end.month = end.year ? 12 : today.getMonth() + 1;
66
+ get(year) {
67
+ year = parseInt(trailingSlash.remove(year));
68
+ return this.inRange(year)
69
+ ? monthsForYearMap(year, this.start, this.end, this.valueFn)
70
+ : undefined;
46
71
  }
47
- if (end.year === undefined) {
48
- end.year = today.getFullYear();
72
+
73
+ inRange(year) {
74
+ return year >= this.start.year && year <= this.end.year;
49
75
  }
50
76
 
51
- return yearsTree(start, end, valueFn);
77
+ keys() {
78
+ return Array.from(
79
+ { length: this.end.year - this.start.year + 1 },
80
+ (_, i) => this.start.year + i
81
+ )[Symbol.iterator]();
82
+ }
52
83
  }
53
84
 
54
85
  function dateParts(date) {
@@ -64,9 +95,9 @@ function dateParts(date) {
64
95
  return { year, month, day };
65
96
  }
66
97
 
67
- function daysForMonthTree(year, month, start, end, valueFn) {
68
- return {
69
- async get(day) {
98
+ function daysForMonthMap(year, month, start, end, valueFn) {
99
+ return Object.assign(new SyncMap(), {
100
+ get(day) {
70
101
  day = parseInt(trailingSlash.remove(day));
71
102
  return this.inRange(day)
72
103
  ? valueFn(year.toString(), twoDigits(month), twoDigits(day))
@@ -101,28 +132,34 @@ function daysForMonthTree(year, month, start, end, valueFn) {
101
132
  }
102
133
  },
103
134
 
104
- async keys() {
135
+ *keys() {
105
136
  const days = Array.from(
106
137
  { length: daysInMonth(year, month) },
107
138
  (_, i) => i + 1
108
139
  );
109
- return days
140
+ yield* days
110
141
  .filter((day) => this.inRange(day))
111
142
  .map((day) => twoDigits(day));
112
143
  },
113
- };
144
+
145
+ trailingSlashKeys: false,
146
+ });
114
147
  }
115
148
 
116
149
  function daysInMonth(year, month) {
117
150
  return new Date(year, month, 0).getDate();
118
151
  }
119
152
 
120
- function monthsForYearTree(year, start, end, valueFn) {
121
- return {
122
- async get(month) {
153
+ function defaultValueFn(year, month, day) {
154
+ return `${year}-${month}-${day}`;
155
+ }
156
+
157
+ function monthsForYearMap(year, start, end, valueFn) {
158
+ return Object.assign(new SyncMap(), {
159
+ get(month) {
123
160
  month = parseInt(trailingSlash.remove(month));
124
161
  return this.inRange(month)
125
- ? daysForMonthTree(year, month, start, end, valueFn)
162
+ ? daysForMonthMap(year, month, start, end, valueFn)
126
163
  : undefined;
127
164
  },
128
165
 
@@ -138,37 +175,17 @@ function monthsForYearTree(year, start, end, valueFn) {
138
175
  }
139
176
  },
140
177
 
141
- async keys() {
178
+ *keys() {
142
179
  const months = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
143
- return months
180
+ yield* months
144
181
  .filter((month) => this.inRange(month))
145
182
  .map((month) => twoDigits(month));
146
183
  },
147
- };
184
+
185
+ trailingSlashKeys: false,
186
+ });
148
187
  }
149
188
 
150
189
  function twoDigits(number) {
151
190
  return number.toString().padStart(2, "0");
152
191
  }
153
-
154
- function yearsTree(start, end, valueFn) {
155
- return {
156
- async get(year) {
157
- year = parseInt(trailingSlash.remove(year));
158
- return this.inRange(year)
159
- ? monthsForYearTree(year, start, end, valueFn)
160
- : undefined;
161
- },
162
-
163
- inRange(year) {
164
- return year >= start.year && year <= end.year;
165
- },
166
-
167
- async keys() {
168
- return Array.from(
169
- { length: end.year - start.year + 1 },
170
- (_, i) => start.year + i
171
- );
172
- },
173
- };
174
- }
@@ -0,0 +1,28 @@
1
+ import * as trailingSlash from "../trailingSlash.js";
2
+ import SyncMap from "./SyncMap.js";
3
+
4
+ /**
5
+ * A tree that returns a constant value for any key. If the key ends with a
6
+ * slash, then the same type of subtree is returned.
7
+ *
8
+ * @param {any} constant
9
+ * @returns {SyncMap}
10
+ */
11
+ export default class ConstantTree extends SyncMap {
12
+ constructor(constant) {
13
+ super();
14
+ this.constant = constant;
15
+ }
16
+
17
+ get(key) {
18
+ return trailingSlash.has(key)
19
+ ? new ConstantTree(this.constant)
20
+ : this.constant;
21
+ }
22
+
23
+ keys() {
24
+ return [][Symbol.iterator]();
25
+ }
26
+
27
+ trailingSlashKeys = true;
28
+ }
@@ -1,11 +1,11 @@
1
- import SiteTree from "./SiteTree.js";
1
+ import SiteMap from "./SiteMap.js";
2
2
 
3
3
  /**
4
- * A [SiteTree](SiteTree.html) that implements the [JSON Keys](jsonKeys.html)
4
+ * A [SiteMap](SiteMap.html) that implements the [JSON Keys](jsonKeys.html)
5
5
  * protocol. This enables a `keys()` method that can return the keys of a site
6
6
  * route even though such a mechanism is not built into the HTTP protocol.
7
7
  */
8
- export default class ExplorableSiteTree extends SiteTree {
8
+ export default class ExplorableSiteMap extends SiteMap {
9
9
  /**
10
10
  * @param {string} href
11
11
  */
@@ -24,12 +24,12 @@ export default class ExplorableSiteTree extends SiteTree {
24
24
  .then((response) => (response.ok ? response.text() : null))
25
25
  .then((text) => {
26
26
  try {
27
- return text ? JSON.parse(text) : null;
27
+ return text ? JSON.parse(text) : [];
28
28
  } catch (error) {
29
29
  // Got a response, but it's not JSON. Most likely the site doesn't
30
30
  // actually have a .keys.json file, and is returning a Not Found page,
31
31
  // but hasn't set the correct 404 status code.
32
- return null;
32
+ return [];
33
33
  }
34
34
  });
35
35
  return this.serverKeysPromise;
@@ -39,9 +39,9 @@ export default class ExplorableSiteTree extends SiteTree {
39
39
  * Returns the keys of the site route. For this to work, the route must have a
40
40
  * `.keys.json` file that contains a JSON array of string keys.
41
41
  */
42
- async keys() {
42
+ async *keys() {
43
43
  const serverKeys = await this.getServerKeys();
44
- return serverKeys ?? [];
44
+ yield* serverKeys;
45
45
  }
46
46
 
47
47
  processResponse(response) {
@@ -0,0 +1,238 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { hiddenFileNames } from "../constants.js";
5
+ import * as trailingSlash from "../trailingSlash.js";
6
+ import isPacked from "../utilities/isPacked.js";
7
+ import isStringlike from "../utilities/isStringlike.js";
8
+ import naturalOrder from "../utilities/naturalOrder.js";
9
+ import setParent from "../utilities/setParent.js";
10
+ import SyncMap from "./SyncMap.js";
11
+
12
+ /**
13
+ * A file system folder as a Map.
14
+ *
15
+ * File values are returned as Uint8Array instances. The underlying Node fs API
16
+ * returns file contents as instances of the Node-specific Buffer class, but
17
+ * that class has some incompatible method implementations; see
18
+ * https://nodejs.org/api/buffer.html#buffers-and-typedarrays. For greater
19
+ * compatibility, files are returned as standard Uint8Array instances instead.
20
+ */
21
+ export default class FileMap extends SyncMap {
22
+ constructor(location) {
23
+ if (location instanceof URL) {
24
+ location = location.href;
25
+ } else if (
26
+ !(
27
+ typeof location === "string" ||
28
+ /** @type {any} */ (location) instanceof String
29
+ )
30
+ ) {
31
+ throw new TypeError("FileMap constructor needs a string or URL");
32
+ }
33
+
34
+ super();
35
+ this.dirname = location.startsWith("file://")
36
+ ? fileURLToPath(location)
37
+ : path.resolve(process.cwd(), location);
38
+ }
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
+
60
+ delete(key) {
61
+ if (key === "" || key == null) {
62
+ // Can't have a file with no name or a nullish name
63
+ throw new Error("delete: key was empty or nullish");
64
+ }
65
+
66
+ // What file or directory are we going to delete?
67
+ const stringKey = key != null ? String(key) : "";
68
+ const baseKey = trailingSlash.remove(stringKey);
69
+ const destPath = path.resolve(this.dirname, baseKey);
70
+
71
+ try {
72
+ fs.rmSync(destPath, { recursive: true });
73
+ return true;
74
+ } catch (/** @type {any} */ error) {
75
+ if (error.code === "ENOENT") {
76
+ return false; // File or directory didn't exist
77
+ }
78
+ throw error;
79
+ }
80
+ }
81
+
82
+ get(key) {
83
+ if (key == null) {
84
+ // Reject nullish key
85
+ throw new ReferenceError(
86
+ `${this.constructor.name}: Cannot get a null or undefined key.`
87
+ );
88
+ }
89
+ if (key === "") {
90
+ // Can't have a file with no name
91
+ return undefined;
92
+ }
93
+
94
+ key = trailingSlash.remove(key); // normalize key
95
+ const filePath = path.resolve(this.dirname, key);
96
+
97
+ const stats = getStats(filePath);
98
+ if (stats === null) {
99
+ return undefined; // File or directory doesn't exist
100
+ }
101
+
102
+ let value;
103
+ if (stats.isDirectory()) {
104
+ // Return subdirectory as an instance of this class
105
+ value = Reflect.construct(this.constructor, [filePath]);
106
+ } else {
107
+ // Return file contents as a standard Uint8Array
108
+ const buffer = fs.readFileSync(filePath);
109
+ value = Uint8Array.from(buffer);
110
+ }
111
+
112
+ value.parent =
113
+ key === ".."
114
+ ? // Special case: ".." parent is the grandparent (if it exists)
115
+ this.parent?.parent
116
+ : this;
117
+ setParent(value, this);
118
+
119
+ return value;
120
+ }
121
+
122
+ keys() {
123
+ let dirEntries;
124
+ try {
125
+ dirEntries = fs.readdirSync(this.dirname, { withFileTypes: true });
126
+ } catch (/** @type {any} */ error) {
127
+ if (error.code !== "ENOENT") {
128
+ throw error;
129
+ }
130
+ // Directory doesn't exist yet; treat as empty
131
+ dirEntries = [];
132
+ }
133
+
134
+ // Add slashes to directory names.
135
+ let names = dirEntries.map((dirEntry) =>
136
+ trailingSlash.toggle(dirEntry.name, dirEntry.isDirectory())
137
+ );
138
+
139
+ // Filter out unhelpful file names.
140
+ names = names.filter((name) => !hiddenFileNames.includes(name));
141
+
142
+ // Node fs.readdir sort order appears to be unreliable; see, e.g.,
143
+ // https://github.com/nodejs/node/issues/3232.
144
+ names.sort(naturalOrder);
145
+
146
+ return names[Symbol.iterator]();
147
+ }
148
+
149
+ get path() {
150
+ return this.dirname;
151
+ }
152
+
153
+ set(key, value) {
154
+ // Where are we going to write this value?
155
+ const stringKey = key != null ? String(key) : "";
156
+ const normalized = trailingSlash.remove(stringKey);
157
+ const destPath = path.resolve(this.dirname, normalized);
158
+
159
+ // Ensure this directory exists.
160
+ const dirname = path.dirname(destPath);
161
+ fs.mkdirSync(dirname, { recursive: true });
162
+
163
+ if (value === undefined) {
164
+ // Special case: undefined value is equivalent to delete()
165
+ this.delete(normalized);
166
+ return this;
167
+ }
168
+
169
+ if (typeof value === "function") {
170
+ // Invoke function; write out the result.
171
+ value = value();
172
+ }
173
+
174
+ let packed = false;
175
+ if (value === null) {
176
+ // Treat null value as empty string; will create an empty file.
177
+ value = "";
178
+ packed = true;
179
+ } else if (value instanceof ArrayBuffer) {
180
+ // Convert ArrayBuffer to Uint8Array, which Node.js can write directly.
181
+ value = new Uint8Array(value);
182
+ packed = true;
183
+ } else if (!(value instanceof String) && isPacked(value)) {
184
+ // As of Node 22, fs.writeFile is incredibly slow for large String
185
+ // instances. Instead of treating a String instance as a Packed value, we
186
+ // want to consider it as a stringlike below. That will convert it to a
187
+ // primitive string before writing — which is orders of magnitude faster.
188
+ packed = true;
189
+ } else if (typeof value.pack === "function") {
190
+ // Pack the value for writing.
191
+ value = value.pack();
192
+ packed = true;
193
+ } else if (isStringlike(value)) {
194
+ // Value has a meaningful `toString` method, use that.
195
+ value = String(value);
196
+ packed = true;
197
+ }
198
+
199
+ if (packed) {
200
+ writeFile(value, destPath);
201
+ } else {
202
+ const typeName = value?.constructor?.name ?? "unknown";
203
+ throw new TypeError(
204
+ `Cannot write a value of type ${typeName} as ${stringKey}`
205
+ );
206
+ }
207
+
208
+ return this;
209
+ }
210
+
211
+ trailingSlashKeys = true;
212
+ }
213
+
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;
224
+ }
225
+ return stats;
226
+ }
227
+
228
+ // Write a value to a file.
229
+ function writeFile(value, destPath) {
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 });
234
+ }
235
+
236
+ // Write out the value as the contents of a file.
237
+ fs.writeFileSync(destPath, value);
238
+ }