1
0

run npm install to generate a package lock

This commit is contained in:
sashinexists
2024-12-07 13:18:31 +11:00
parent e7d08a91b5
commit 23437d228e
2501 changed files with 290663 additions and 0 deletions

1
node_modules/@weborigami/async-tree/ReadMe.md generated vendored Normal file
View File

@@ -0,0 +1 @@
This library contains definitions for asynchronous trees backed by standard JavaScript classes like `Object` and `Map` and standard browser APIs such as the [Origin Private File System](https://developer.mozilla.org/en-US/docs/Web/API/File_System_API/Origin_private_file_system). The library also includes collections of helpers for common tree operations.

4
node_modules/@weborigami/async-tree/browser.js generated vendored Normal file
View File

@@ -0,0 +1,4 @@
// Exports for browser
export * from "./shared.js";
export { default as BrowserFileTree } from "./src/drivers/BrowserFileTree.js";

72
node_modules/@weborigami/async-tree/index.ts generated vendored Normal file
View File

@@ -0,0 +1,72 @@
import type { AsyncTree } from "@weborigami/types";
export * from "./main.js";
export type KeyFn = (key: any, innerTree: AsyncTree) => any;
/**
* An object with a non-trivial `toString` method.
*
* TODO: We want to deliberately exclude the base `Object` class because its
* `toString` method return non-useful strings like `[object Object]`. How can
* we declare that in TypeScript?
*/
export type HasString = {
toString(): string;
};
/**
* A packed value is one that can be written to a file via fs.writeFile or into
* an HTTP response via response.write, or readily converted to such a form.
*/
export type Packed = (ArrayBuffer | Buffer | ReadableStream | string | String | TypedArray) & {
unpack?(): Promise<any>;
};
export type PlainObject = {
[key: string]: any;
};
export type ReduceFn = (values: any[], keys: any[], tree: AsyncTree) => Promise<any>;
export type StringLike = string | HasString;
export type NativeTreelike =
any[] |
AsyncTree |
Function |
Map<any, any> |
PlainObject |
Set<any>;
export type Treelike =
NativeTreelike |
Unpackable<NativeTreelike>;
export type TreeMapOptions = {
deep?: boolean;
description?: string;
needsSourceValue?: boolean;
inverseKey?: KeyFn;
key?: KeyFn;
value?: ValueKeyFn;
};
export type TreeTransform = (treelike: Treelike) => AsyncTree;
export type TypedArray =
Float32Array |
Float64Array |
Int8Array |
Int16Array |
Int32Array |
Uint8Array |
Uint8ClampedArray |
Uint16Array |
Uint32Array;
export type Unpackable<T> = {
unpack(): Promise<T>
};
export type ValueKeyFn = (value: any, key: any, innerTree: AsyncTree) => any;

5
node_modules/@weborigami/async-tree/main.js generated vendored Normal file
View File

@@ -0,0 +1,5 @@
// Exports for Node.js
export * from "./shared.js";
export { default as FileTree } from "./src/drivers/FileTree.js";
export * as extension from "./src/extension.js";

20
node_modules/@weborigami/async-tree/package.json generated vendored Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "@weborigami/async-tree",
"version": "0.2.1",
"description": "Asynchronous tree drivers based on standard JavaScript classes",
"type": "module",
"main": "./main.js",
"browser": "./browser.js",
"types": "./index.ts",
"devDependencies": {
"@types/node": "22.7.4",
"typescript": "5.6.2"
},
"dependencies": {
"@weborigami/types": "0.2.1"
},
"scripts": {
"test": "node --test --test-reporter=spec",
"typecheck": "tsc"
}
}

32
node_modules/@weborigami/async-tree/shared.js generated vendored Normal file
View File

@@ -0,0 +1,32 @@
// Exports for both Node.js and browser
export { default as calendarTree } from "./src/drivers/calendarTree.js";
export { default as DeepMapTree } from "./src/drivers/DeepMapTree.js";
export { default as DeferredTree } from "./src/drivers/DeferredTree.js";
export { default as ExplorableSiteTree } from "./src/drivers/ExplorableSiteTree.js";
export { default as FunctionTree } from "./src/drivers/FunctionTree.js";
export { default as MapTree } from "./src/drivers/MapTree.js";
export { default as SetTree } from "./src/drivers/SetTree.js";
export { default as SiteTree } from "./src/drivers/SiteTree.js";
export { DeepObjectTree, ObjectTree, Tree } from "./src/internal.js";
export * as jsonKeys from "./src/jsonKeys.js";
export { default as cache } from "./src/operations/cache.js";
export { default as cachedKeyFunctions } from "./src/operations/cachedKeyFunctions.js";
export { default as concat } from "./src/operations/concat.js";
export { default as deepMerge } from "./src/operations/deepMerge.js";
export { default as deepReverse } from "./src/operations/deepReverse.js";
export { default as deepTake } from "./src/operations/deepTake.js";
export { default as deepValues } from "./src/operations/deepValues.js";
export { default as deepValuesIterator } from "./src/operations/deepValuesIterator.js";
export { default as group } from "./src/operations/group.js";
export { default as invokeFunctions } from "./src/operations/invokeFunctions.js";
export { default as keyFunctionsForExtensions } from "./src/operations/keyFunctionsForExtensions.js";
export { default as map } from "./src/operations/map.js";
export { default as merge } from "./src/operations/merge.js";
export { default as reverse } from "./src/operations/reverse.js";
export { default as scope } from "./src/operations/scope.js";
export { default as sort } from "./src/operations/sort.js";
export { default as take } from "./src/operations/take.js";
export * as symbols from "./src/symbols.js";
export * as trailingSlash from "./src/trailingSlash.js";
export * from "./src/utilities.js";

24
node_modules/@weborigami/async-tree/src/Tree.d.ts generated vendored Normal file
View File

@@ -0,0 +1,24 @@
import type { AsyncMutableTree, AsyncTree } from "@weborigami/types";
import { PlainObject, ReduceFn, Treelike, TreeMapOptions, ValueKeyFn } from "../index.ts";
export function assign(target: Treelike, source: Treelike): Promise<AsyncTree>;
export function clear(AsyncTree: AsyncMutableTree): Promise<void>;
export function entries(AsyncTree: AsyncTree): Promise<IterableIterator<any>>;
export function forEach(AsyncTree: AsyncTree, callbackfn: (value: any, key: any) => Promise<void>): Promise<void>;
export function from(obj: any, options?: { deep?: boolean, parent?: AsyncTree|null }): AsyncTree;
export function has(AsyncTree: AsyncTree, key: any): Promise<boolean>;
export function isAsyncMutableTree(obj: any): obj is AsyncMutableTree;
export function isAsyncTree(obj: any): obj is AsyncTree;
export function isTraversable(obj: any): boolean;
export function isTreelike(obj: any): obj is Treelike;
export function map(tree: Treelike, options: TreeMapOptions|ValueKeyFn): AsyncTree;
export function mapReduce(tree: Treelike, mapFn: ValueKeyFn | null, reduceFn: ReduceFn): Promise<any>;
export function paths(tree: Treelike, base?: string): string[];
export function plain(tree: Treelike): Promise<PlainObject>;
export function root(tree: Treelike): AsyncTree;
export function remove(AsyncTree: AsyncMutableTree, key: any): Promise<boolean>;
export function toFunction(tree: Treelike): Function;
export function traverse(tree: Treelike, ...keys: any[]): Promise<any>;
export function traverseOrThrow(tree: Treelike, ...keys: any[]): Promise<any>;
export function traversePath(tree: Treelike, path: string): Promise<any>;
export function values(AsyncTree: AsyncTree): Promise<IterableIterator<any>>;

497
node_modules/@weborigami/async-tree/src/Tree.js generated vendored Normal file
View File

@@ -0,0 +1,497 @@
import DeferredTree from "./drivers/DeferredTree.js";
import FunctionTree from "./drivers/FunctionTree.js";
import MapTree from "./drivers/MapTree.js";
import SetTree from "./drivers/SetTree.js";
import { DeepObjectTree, ObjectTree } from "./internal.js";
import * as symbols from "./symbols.js";
import * as trailingSlash from "./trailingSlash.js";
import * as utilities from "./utilities.js";
import {
castArrayLike,
isPacked,
isPlainObject,
isUnpackable,
toPlainValue,
} from "./utilities.js";
/**
* Helper functions for working with async trees
*
* @typedef {import("../index.ts").PlainObject} PlainObject
* @typedef {import("../index.ts").ReduceFn} ReduceFn
* @typedef {import("../index.ts").Treelike} Treelike
* @typedef {import("../index.ts").ValueKeyFn} ValueKeyFn
* @typedef {import("@weborigami/types").AsyncMutableTree} AsyncMutableTree
* @typedef {import("@weborigami/types").AsyncTree} AsyncTree
*/
const treeModule = this;
/**
* Apply the key/values pairs from the source tree to the target tree.
*
* If a key exists in both trees, and the values in both trees are
* subtrees, then the subtrees will be merged recursively. Otherwise, the
* value from the source tree will overwrite the value in the target tree.
*
* @param {AsyncMutableTree} target
* @param {AsyncTree} source
*/
export async function assign(target, source) {
const targetTree = from(target);
const sourceTree = from(source);
if (!isAsyncMutableTree(targetTree)) {
throw new TypeError("Target must be a mutable asynchronous tree");
}
// Fire off requests to update all keys, then wait for all of them to finish.
const keys = Array.from(await sourceTree.keys());
const promises = keys.map(async (key) => {
const sourceValue = await sourceTree.get(key);
if (isAsyncTree(sourceValue)) {
const targetValue = await targetTree.get(key);
if (isAsyncMutableTree(targetValue)) {
// Both source and target are trees; recurse.
await assign(targetValue, sourceValue);
return;
}
}
// Copy the value from the source to the target.
await /** @type {any} */ (targetTree).set(key, sourceValue);
});
await Promise.all(promises);
return targetTree;
}
/**
* Removes all entries from the tree.
*
* @param {AsyncMutableTree} tree
*/
export async function clear(tree) {
const keys = Array.from(await tree.keys());
const promises = keys.map((key) => tree.set(key, undefined));
await Promise.all(promises);
}
/**
* Returns a new `Iterator` object that contains a two-member array of `[key,
* value]` for each element in the specific node of the tree.
*
* @param {AsyncTree} tree
*/
export async function entries(tree) {
const keys = Array.from(await tree.keys());
const promises = keys.map(async (key) => [key, await tree.get(key)]);
return Promise.all(promises);
}
/**
* Calls callbackFn once for each key-value pair present in the specific node of
* the tree.
*
* @param {AsyncTree} tree
* @param {Function} callbackFn
*/
export async function forEach(tree, callbackFn) {
const keys = Array.from(await tree.keys());
const promises = keys.map(async (key) => {
const value = await tree.get(key);
return callbackFn(value, key);
});
await Promise.all(promises);
}
/**
* Attempts to cast the indicated object to an async tree.
*
* If the object is a plain object, it will be converted to an ObjectTree. The
* optional `deep` option can be set to `true` to convert a plain object to a
* DeepObjectTree. The optional `parent` parameter will be used as the default
* parent of the new tree.
*
* @param {Treelike | Object} object
* @param {{ deep?: boolean, parent?: AsyncTree|null }} [options]
* @returns {AsyncTree}
*/
export function from(object, options = {}) {
const deep = options.deep ?? object[symbols.deep];
let tree;
if (isAsyncTree(object)) {
// Argument already supports the tree interface.
// @ts-ignore
return object;
} else if (typeof object === "function") {
tree = new FunctionTree(object);
} else if (object instanceof Map) {
tree = new MapTree(object);
} else if (object instanceof Set) {
tree = new SetTree(object);
} else if (isPlainObject(object) || object instanceof Array) {
tree = deep ? new DeepObjectTree(object) : new ObjectTree(object);
} else if (isUnpackable(object)) {
async function AsyncFunction() {} // Sample async function
tree =
object.unpack instanceof AsyncFunction.constructor
? // Async unpack: return a deferred tree.
new DeferredTree(object.unpack, { deep })
: // Synchronous unpack: cast the result of unpack() to a tree.
from(object.unpack());
} else if (object && typeof object === "object") {
// An instance of some class.
tree = new ObjectTree(object);
} else if (
typeof object === "string" ||
typeof object === "number" ||
typeof object === "boolean"
) {
// A primitive value; box it into an object and construct a tree.
const boxed = utilities.box(object);
tree = new ObjectTree(boxed);
} else {
throw new TypeError("Couldn't convert argument to an async tree");
}
if (!tree.parent && options.parent) {
tree.parent = options.parent;
}
return tree;
}
/**
* Returns a boolean indicating whether the specific node of the tree has a
* value for the given `key`.
*
* @param {AsyncTree} tree
* @param {any} key
*/
export async function has(tree, key) {
const value = await tree.get(key);
return value !== undefined;
}
/**
* Return true if the indicated object is an async tree.
*
* @param {any} obj
* @returns {obj is AsyncTree}
*/
export function isAsyncTree(obj) {
return (
obj !== null &&
typeof obj === "object" &&
typeof obj.get === "function" &&
typeof obj.keys === "function" &&
// JavaScript Map look like trees but can't be extended the same way, so we
// report false.
!(obj instanceof Map)
);
}
/**
* Return true if the indicated object is an async mutable tree.
*
* @param {any} obj
* @returns {obj is AsyncMutableTree}
*/
export function isAsyncMutableTree(obj) {
return (
isAsyncTree(obj) && typeof (/** @type {any} */ (obj).set) === "function"
);
}
/**
* Return true if the object can be traversed via the `traverse()` method. The
* object must be either treelike or a packed object with an `unpack()` method.
*
* @param {any} object
*/
export function isTraversable(object) {
return (
isTreelike(object) ||
(isPacked(object) && /** @type {any} */ (object).unpack instanceof Function)
);
}
/**
* Returns true if the indicated object can be directly treated as an
* asynchronous tree. This includes:
*
* - An object that implements the AsyncTree interface (including
* AsyncTree instances)
* - A function
* - An `Array` instance
* - A `Map` instance
* - A `Set` instance
* - A plain object
*
* Note: the `from()` method accepts any JavaScript object, but `isTreelike`
* returns `false` for an object that isn't one of the above types.
*
* @param {any} obj
* @returns {obj is Treelike}
*/
export function isTreelike(obj) {
return (
isAsyncTree(obj) ||
obj instanceof Array ||
obj instanceof Function ||
obj instanceof Map ||
obj instanceof Set ||
isPlainObject(obj)
);
}
/**
* Return a new tree with deeply-mapped values of the original tree.
*
* @param {Treelike} treelike
* @param {ValueKeyFn} valueFn
*/
export { default as map } from "./operations/map.js";
/**
* Map and reduce a tree.
*
* This is done in as parallel fashion as possible. Each of the tree's values
* will be requested in an async call, then those results will be awaited
* collectively. If a mapFn is provided, it will be invoked to convert each
* value to a mapped value; otherwise, values will be used as is. When the
* values have been obtained, all the values and keys will be passed to the
* reduceFn, which should consolidate those into a single result.
*
* @param {Treelike} treelike
* @param {ValueKeyFn|null} valueFn
* @param {ReduceFn} reduceFn
*/
export async function mapReduce(treelike, valueFn, reduceFn) {
const tree = from(treelike);
// We're going to fire off all the get requests in parallel, as quickly as
// the keys come in. We call the tree's `get` method for each key, but
// *don't* wait for it yet.
const keys = Array.from(await tree.keys());
const promises = keys.map((key) =>
tree.get(key).then((value) =>
// If the value is a subtree, recurse.
isAsyncTree(value)
? mapReduce(value, valueFn, reduceFn)
: valueFn
? valueFn(value, key, tree)
: value
)
);
// Wait for all the promises to resolve. Because the promises were captured
// in the same order as the keys, the values will also be in the same order.
const values = await Promise.all(promises);
// Reduce the values to a single result.
return reduceFn(values, keys, tree);
}
/**
* Returns slash-separated paths for all values in the tree.
*
* @param {Treelike} treelike
* @param {string?} base
*/
export async function paths(treelike, base = "") {
const tree = from(treelike);
const result = [];
for (const key of await tree.keys()) {
const separator = trailingSlash.has(base) ? "" : "/";
const valuePath = base ? `${base}${separator}${key}` : key;
const value = await tree.get(key);
if (await isAsyncTree(value)) {
const subPaths = await paths(value, valuePath);
result.push(...subPaths);
} else {
result.push(valuePath);
}
}
return result;
}
/**
* Converts an asynchronous tree into a synchronous plain JavaScript object.
*
* The result's keys will be the tree's keys cast to strings. Any tree value
* that is itself a tree will be similarly converted to a plain object.
*
* Any trailing slashes in keys will be removed.
*
* @param {Treelike} treelike
* @returns {Promise<PlainObject|Array>}
*/
export async function plain(treelike) {
return mapReduce(treelike, toPlainValue, (values, keys, tree) => {
// Special case for an empty tree: if based on array, return array.
if (tree instanceof ObjectTree && keys.length === 0) {
return /** @type {any} */ (tree).object instanceof Array ? [] : {};
}
// Normalize slashes in keys.
keys = keys.map(trailingSlash.remove);
return castArrayLike(keys, values);
});
}
/**
* Removes the value for the given key from the specific node of the tree.
*
* Note: The corresponding `Map` method is `delete`, not `remove`. However,
* `delete` is a reserved word in JavaScript, so this uses `remove` instead.
*
* @param {AsyncMutableTree} tree
* @param {any} key
*/
export async function remove(tree, key) {
const exists = await has(tree, key);
if (exists) {
await tree.set(key, undefined);
return true;
} else {
return false;
}
}
/**
* Walk up the `parent` chain to find the root of the tree.
*
* @param {AsyncTree} tree
*/
export function root(tree) {
let current = from(tree);
while (current.parent) {
current = current.parent;
}
return current;
}
/**
* Returns a function that invokes the tree's `get` method.
*
* @param {Treelike} treelike
* @returns {Function}
*/
export function toFunction(treelike) {
const tree = from(treelike);
return tree.get.bind(tree);
}
/**
* Return the value at the corresponding path of keys.
*
* @this {any}
* @param {Treelike} treelike
* @param {...any} keys
*/
export async function traverse(treelike, ...keys) {
try {
// Await the result here so that, if the path doesn't exist, the catch
// block below will catch the exception.
return await traverseOrThrow.call(this, treelike, ...keys);
} catch (/** @type {any} */ error) {
if (error instanceof TraverseError) {
return undefined;
} else {
throw error;
}
}
}
/**
* Return the value at the corresponding path of keys. Throw if any interior
* step of the path doesn't lead to a result.
*
* @this {AsyncTree|null|undefined}
* @param {Treelike} treelike
* @param {...any} keys
*/
export async function traverseOrThrow(treelike, ...keys) {
// Start our traversal at the root of the tree.
/** @type {any} */
let value = treelike;
let position = 0;
// If traversal operation was called with a `this` context, use that as the
// target for function calls.
const target = this === treeModule ? undefined : this;
// Process all the keys.
const remainingKeys = keys.slice();
let key;
while (remainingKeys.length > 0) {
if (value === undefined) {
throw new TraverseError("A null or undefined value can't be traversed", {
tree: treelike,
keys,
position,
});
}
// If the value is packed and can be unpacked, unpack it.
if (isUnpackable(value)) {
value = await value.unpack();
}
if (value instanceof Function) {
// Value is a function: call it with the remaining keys.
const fn = value;
// We'll take as many keys as the function's length, but at least one.
let fnKeyCount = Math.max(fn.length, 1);
const args = remainingKeys.splice(0, fnKeyCount);
key = null;
value = await fn.call(target, ...args);
} else if (isTraversable(value) || typeof value === "object") {
// Value is some other treelike object: cast it to a tree.
const tree = from(value);
// Get the next key.
key = remainingKeys.shift();
// Get the value for the key.
value = await tree.get(key);
} else {
// Value can't be traversed
throw new TraverseError("Tried to traverse a value that's not treelike", {
tree: treelike,
keys,
position,
});
}
position++;
}
return value;
}
/**
* Given a slash-separated path like "foo/bar", traverse the keys "foo/" and
* "bar" and return the resulting value.
*
* @param {Treelike} tree
* @param {string} path
*/
export async function traversePath(tree, path) {
const keys = utilities.keysFromPath(path);
return traverse(tree, ...keys);
}
// Error class thrown by traverseOrThrow()
class TraverseError extends ReferenceError {
constructor(message, options) {
super(message);
this.name = "TraverseError";
Object.assign(this, options);
}
}
/**
* Return the values in the specific node of the tree.
*
* @param {AsyncTree} tree
*/
export async function values(tree) {
const keys = Array.from(await tree.keys());
const promises = keys.map(async (key) => tree.get(key));
return Promise.all(promises);
}

View File

@@ -0,0 +1,176 @@
import { Tree } from "../internal.js";
import * as trailingSlash from "../trailingSlash.js";
import {
hiddenFileNames,
isStringLike,
naturalOrder,
setParent,
} from "../utilities.js";
const TypedArray = Object.getPrototypeOf(Uint8Array);
/**
* A tree of files backed by a browser-hosted file system such as the standard
* Origin Private File System or the (as of October 2023) experimental File
* System Access API.
*
* @typedef {import("@weborigami/types").AsyncMutableTree} AsyncMutableTree
* @implements {AsyncMutableTree}
*/
export default class BrowserFileTree {
/**
* Construct a tree of files backed by a browser-hosted file system.
*
* The directory handle can be obtained via any of the [methods that return a
* FileSystemDirectoryHandle](https://developer.mozilla.org/en-US/docs/Web/API/FileSystemDirectoryHandle).
* If no directory is supplied, the tree is rooted at the Origin Private File
* System for the current site.
*
* @param {FileSystemDirectoryHandle} [directoryHandle]
*/
constructor(directoryHandle) {
/** @type {FileSystemDirectoryHandle}
* @ts-ignore */
this.directory = directoryHandle;
}
async get(key) {
if (key == null) {
// Reject nullish key.
throw new ReferenceError(
`${this.constructor.name}: Cannot get a null or undefined key.`
);
}
if (key === "") {
// Can't have a file with no name
return undefined;
}
// Remove trailing slash if present
key = trailingSlash.remove(key);
const directory = await this.getDirectory();
// Try the key as a subfolder name
try {
const subfolderHandle = await directory.getDirectoryHandle(key);
const value = Reflect.construct(this.constructor, [subfolderHandle]);
setParent(value, this);
return value;
} catch (error) {
if (
!(
error instanceof DOMException &&
(error.name === "NotFoundError" || error.name === "TypeMismatchError")
)
) {
throw error;
}
}
// Try the key as a file name
try {
const fileHandle = await directory.getFileHandle(key);
const file = await fileHandle.getFile();
const buffer = file.arrayBuffer();
setParent(buffer, this);
return buffer;
} catch (error) {
if (!(error instanceof DOMException && error.name === "NotFoundError")) {
throw error;
}
}
return undefined;
}
// Return the directory handle, creating it if necessary. We can't create the
// default value in the constructor because we need to await it.
async getDirectory() {
this.directory ??= await navigator.storage.getDirectory();
return this.directory;
}
async keys() {
const directory = await this.getDirectory();
let keys = [];
// @ts-ignore
for await (const entryKey of directory.keys()) {
// Check if the entry is a subfolder
const baseKey = trailingSlash.remove(entryKey);
const subfolderHandle = await directory
.getDirectoryHandle(baseKey)
.catch(() => null);
const isSubfolder = subfolderHandle !== null;
const key = trailingSlash.toggle(entryKey, isSubfolder);
keys.push(key);
}
// Filter out unhelpful file names.
keys = keys.filter((key) => !hiddenFileNames.includes(key));
keys.sort(naturalOrder);
return keys;
}
async set(key, value) {
const baseKey = trailingSlash.remove(key);
const directory = await this.getDirectory();
if (value === undefined) {
// Delete file.
try {
await directory.removeEntry(baseKey);
} catch (error) {
// If the file didn't exist, ignore the error.
if (
!(error instanceof DOMException && error.name === "NotFoundError")
) {
throw error;
}
}
return this;
}
// Treat null value as empty string; will create an empty file.
if (value === null) {
value = "";
}
// True if fs.writeFile can directly write the value to a file.
let isWriteable =
value instanceof ArrayBuffer ||
value instanceof TypedArray ||
value instanceof DataView ||
value instanceof Blob;
if (!isWriteable && isStringLike(value)) {
// Value has a meaningful `toString` method, use that.
value = String(value);
isWriteable = true;
}
if (isWriteable) {
// Write file.
const fileHandle = await directory.getFileHandle(baseKey, {
create: true,
});
const writable = await fileHandle.createWritable();
await writable.write(value);
await writable.close();
} else if (Tree.isTreelike(value)) {
// Treat value as a tree and write it out as a subdirectory.
const subdirectory = await directory.getDirectoryHandle(baseKey, {
create: true,
});
const destTree = Reflect.construct(this.constructor, [subdirectory]);
await Tree.assign(destTree, value);
} else {
const typeName = value?.constructor?.name ?? "unknown";
throw new TypeError(`Cannot write a value of type ${typeName} as ${key}`);
}
return this;
}
}

View File

@@ -0,0 +1,23 @@
import { Tree } from "../internal.js";
import MapTree from "./MapTree.js";
export default class DeepMapTree extends MapTree {
async get(key) {
let value = await super.get(key);
if (value instanceof Map) {
value = Reflect.construct(this.constructor, [value]);
}
if (Tree.isAsyncTree(value) && !value.parent) {
value.parent = this;
}
return value;
}
/** @returns {boolean} */
isSubtree(value) {
return value instanceof Map || Tree.isAsyncTree(value);
}
}

View File

@@ -0,0 +1,19 @@
import { ObjectTree, Tree } from "../internal.js";
import { isPlainObject } from "../utilities.js";
export default class DeepObjectTree extends ObjectTree {
async get(key) {
let value = await super.get(key);
if (value instanceof Array || isPlainObject(value)) {
value = Reflect.construct(this.constructor, [value]);
}
return value;
}
/** @returns {boolean} */
isSubtree(value) {
return (
value instanceof Array || isPlainObject(value) || Tree.isAsyncTree(value)
);
}
}

View File

@@ -0,0 +1,81 @@
import { Tree } from "../internal.js";
/**
* A tree that is loaded lazily.
*
* This is useful in situations that must return a tree synchronously. If
* constructing the tree requires an asynchronous operation, this class can be
* used as a wrapper that can be returned immediately. The tree will be loaded
* the first time the keys() or get() functions are called.
*
* @typedef {import("@weborigami/types").AsyncTree} AsyncTree
* @implements {AsyncTree}
*/
export default class DeferredTree {
/**
* @param {Function|Promise<any>} loader
* @param {{ deep?: boolean }} [options]
*/
constructor(loader, options) {
this.loader = loader;
this.treePromise = null;
this._tree = null;
this._parentUntilLoaded = null;
this._deep = options?.deep;
}
async get(key) {
const tree = await this.tree();
return tree.get(key);
}
async loadResult() {
if (!(this.loader instanceof Promise)) {
this.loader = this.loader();
}
return this.loader;
}
async keys() {
const tree = await this.tree();
return tree.keys();
}
// A deferred tree's parent generally comes from the loaded tree. However, if
// someone tries to get or set the parent before the tree is loaded, we store
// that parent reference and apply it once the tree is loaded.
get parent() {
return this._tree?.parent ?? this._parentUntilLoaded;
}
set parent(parent) {
if (this._tree && !this._tree.parent) {
this._tree.parent = parent;
} else {
this._parentUntilLoaded = parent;
}
}
async tree() {
if (this._tree) {
return this._tree;
}
// Use a promise to ensure the treelike is only converted to a tree once.
this.treePromise ??= this.loadResult().then((treelike) => {
const options =
this._deep !== undefined ? { deep: this._deep } : undefined;
this._tree = Tree.from(treelike, options);
if (this._parentUntilLoaded) {
// Now that the tree has been loaded, we can set its parent if it hasn't
// already been set.
if (!this._tree.parent) {
this._tree.parent = this._parentUntilLoaded;
}
this._parentUntilLoaded = null;
}
return this._tree;
});
return this.treePromise;
}
}

View File

@@ -0,0 +1,52 @@
import SiteTree from "./SiteTree.js";
/**
* A [SiteTree](SiteTree.html) that implements the [JSON Keys](jsonKeys.html)
* protocol. This enables a `keys()` method that can return the keys of a site
* route even though such a mechanism is not built into the HTTP protocol.
*/
export default class ExplorableSiteTree extends SiteTree {
constructor(...args) {
super(...args);
this.serverKeysPromise = undefined;
}
async getServerKeys() {
// We use a promise to ensure we only check for keys once.
const href = new URL(".keys.json", this.href).href;
this.serverKeysPromise ??= fetch(href)
.then((response) => (response.ok ? response.text() : null))
.then((text) => {
try {
return text ? JSON.parse(text) : null;
} catch (error) {
// Got a response, but it's not JSON. Most likely the site doesn't
// actually have a .keys.json file, and is returning a Not Found page,
// but hasn't set the correct 404 status code.
return null;
}
});
return this.serverKeysPromise;
}
/**
* Returns the keys of the site route. For this to work, the route must have a
* `.keys.json` file that contains a JSON array of string keys.
*
* @returns {Promise<Iterable<string>>}
*/
async keys() {
const serverKeys = await this.getServerKeys();
return serverKeys ?? [];
}
processResponse(response) {
// If the response was redirected to a route that ends with a slash, and the
// site is an explorable site, we return a tree for the new route.
if (response.ok && response.redirected && response.url.endsWith("/")) {
return Reflect.construct(this.constructor, [response.url]);
}
return super.processResponse(response);
}
}

View File

@@ -0,0 +1,268 @@
import * as fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { Tree } from "../internal.js";
import * as trailingSlash from "../trailingSlash.js";
import {
getRealmObjectPrototype,
hiddenFileNames,
isPacked,
isPlainObject,
naturalOrder,
setParent,
} from "../utilities.js";
/**
* A file system tree via the Node file system API.
*
* File values are returned as Uint8Array instances. The underlying Node fs API
* returns file contents as instances of the node-specific Buffer class, but
* that class has some incompatible method implementations; see
* https://nodejs.org/api/buffer.html#buffers-and-typedarrays. For greater
* compatibility, files are returned as standard Uint8Array instances instead.
*
* @typedef {import("@weborigami/types").AsyncMutableTree} AsyncMutableTree
* @implements {AsyncMutableTree}
*/
export default class FileTree {
/**
* @param {string|URL} location
*/
constructor(location) {
if (location instanceof URL) {
location = location.href;
} else if (
!(
typeof location === "string" ||
/** @type {any} */ (location) instanceof String
)
) {
throw new TypeError(
`FileTree constructor needs a string or URL, received an instance of ${
/** @type {any} */ (location)?.constructor?.name
}`
);
}
this.dirname = location.startsWith("file://")
? fileURLToPath(location)
: path.resolve(process.cwd(), location);
this.parent = null;
}
async get(key) {
if (key == null) {
// Reject nullish key
throw new ReferenceError(
`${this.constructor.name}: Cannot get a null or undefined key.`
);
}
if (key === "") {
// Can't have a file with no name
return undefined;
}
// Remove trailing slash if present
key = trailingSlash.remove(key);
const filePath = path.resolve(this.dirname, key);
let stats;
try {
stats = await fs.stat(filePath);
} catch (/** @type {any} */ error) {
if (error.code === "ENOENT" /* File not found */) {
return undefined;
}
throw error;
}
let value;
if (stats.isDirectory()) {
// Return subdirectory as a tree
value = Reflect.construct(this.constructor, [filePath]);
} else {
// Return file contents as a standard Uint8Array
const buffer = await fs.readFile(filePath);
value = Uint8Array.from(buffer);
}
setParent(value, this);
return value;
}
/**
* Enumerate the names of the files/subdirectories in this directory.
*/
async keys() {
let entries;
try {
entries = await fs.readdir(this.dirname, { withFileTypes: true });
} catch (/** @type {any} */ error) {
if (error.code !== "ENOENT") {
throw error;
}
entries = [];
}
// Add slashes to directory names.
let names = await Promise.all(
entries.map(async (entry) =>
trailingSlash.toggle(entry.name, await isDirectory(entry, this.dirname))
)
);
// Filter out unhelpful file names.
names = names.filter((name) => !hiddenFileNames.includes(name));
// Node fs.readdir sort order appears to be unreliable; see, e.g.,
// https://github.com/nodejs/node/issues/3232.
names.sort(naturalOrder);
return names;
}
get path() {
return this.dirname;
}
async set(key, value) {
// Where are we going to write this value?
const stringKey = key != null ? String(key) : "";
const baseKey = trailingSlash.remove(stringKey);
const destPath = path.resolve(this.dirname, baseKey);
if (value === undefined) {
// Delete the file or directory.
let stats;
try {
stats = await stat(destPath);
} catch (/** @type {any} */ error) {
if (error.code === "ENOENT" /* File not found */) {
return this;
}
throw error;
}
if (stats?.isDirectory()) {
// Delete directory.
await fs.rm(destPath, { recursive: true });
} else if (stats) {
// Delete file.
await fs.unlink(destPath);
}
return this;
}
if (typeof value === "function") {
// Invoke function; write out the result.
value = await value();
}
let packed = false;
if (value === null) {
// Treat null value as empty string; will create an empty file.
value = "";
packed = true;
} else if (!(value instanceof String) && isPacked(value)) {
// As of Node 22, fs.writeFile is incredibly slow for large String
// instances. Instead of treating a String instance as a Packed value, we
// want to consider it as a stringlike below. That will convert it to a
// primitive string before writing — which is orders of magnitude faster.
packed = true;
} else if (typeof value.pack === "function") {
// Pack the value for writing.
value = await value.pack();
packed = true;
} else if (isStringLike(value)) {
// Value has a meaningful `toString` method, use that.
value = String(value);
packed = true;
}
if (packed) {
// Single writeable value.
if (value instanceof ArrayBuffer) {
// Convert ArrayBuffer to Uint8Array, which Node.js can write directly.
value = new Uint8Array(value);
}
// Ensure this directory exists.
await fs.mkdir(this.dirname, { recursive: true });
// Write out the value as the contents of a file.
await fs.writeFile(destPath, value);
} else if (isPlainObject(value) && Object.keys(value).length === 0) {
// Special case: empty object means create an empty directory.
await fs.mkdir(destPath, { recursive: true });
} else if (Tree.isTreelike(value)) {
// Treat value as a subtree and write it out as a subdirectory.
const destTree = Reflect.construct(this.constructor, [destPath]);
// Create the directory here, even if the subtree is empty.
await fs.mkdir(destPath, { recursive: true });
// Write out the subtree.
await Tree.assign(destTree, value);
} else {
const typeName = value?.constructor?.name ?? "unknown";
throw new TypeError(
`Cannot write a value of type ${typeName} as ${stringKey}`
);
}
return this;
}
get url() {
return pathToFileURL(this.dirname);
}
}
/**
* Return true if the entry is a directory or is a symbolic link to a directory.
*/
async function isDirectory(entry, dirname) {
if (entry.isSymbolicLink()) {
const entryPath = path.resolve(dirname, entry.name);
try {
const realPath = await fs.realpath(entryPath);
entry = await fs.stat(realPath);
} catch (error) {
// The slash isn't crucial, so if link doesn't work that's okay
return false;
}
}
return entry.isDirectory();
}
/**
* Return true if the object is a string or object with a non-trival `toString`
* method.
*
* @param {any} obj
*/
function isStringLike(obj) {
if (typeof obj === "string") {
return true;
} else if (obj?.toString === undefined) {
return false;
} else if (obj.toString === getRealmObjectPrototype(obj)?.toString) {
// The stupid Object.prototype.toString implementation always returns
// "[object Object]", so if that's the only toString method the object has,
// we return false.
return false;
} else {
return true;
}
}
// Return the file information for the file/folder at the given path.
// If it does not exist, return undefined.
async function stat(filePath) {
try {
// Await the result here so that, if the file doesn't exist, the catch block
// below will catch the exception.
return await fs.stat(filePath);
} catch (/** @type {any} */ error) {
if (error.code === "ENOENT" /* File not found */) {
return undefined;
}
throw error;
}
}

View File

@@ -0,0 +1,46 @@
import { setParent } from "../utilities.js";
/**
* A tree defined by a function and an optional domain.
*
* @typedef {import("@weborigami/types").AsyncTree} AsyncTree
* @implements {AsyncTree}
*/
export default class FunctionTree {
/**
* @param {function} fn the key->value function
* @param {Iterable<any>} [domain] optional domain of the function
*/
constructor(fn, domain = []) {
this.fn = fn;
this.domain = domain;
this.parent = null;
}
/**
* Return the application of the function to the given key.
*
* @param {any} key
*/
async get(key) {
const value =
this.fn.length <= 1
? // Function takes no arguments, one argument, or a variable number of
// arguments: invoke it.
await this.fn.call(this.parent, key)
: // Bind the key to the first parameter. Subsequent get calls will
// eventually bind all parameters until only one remains. At that point,
// the above condition will apply and the function will be invoked.
Reflect.construct(this.constructor, [this.fn.bind(this.parent, key)]);
setParent(value, this);
return value;
}
/**
* Enumerates the function's domain (if defined) as the tree's keys. If no domain
* was defined, this returns an empty iterator.
*/
async keys() {
return this.domain;
}
}

View File

@@ -0,0 +1,67 @@
import { Tree } from "../internal.js";
import * as trailingSlash from "../trailingSlash.js";
import { setParent } from "../utilities.js";
/**
* A tree backed by a JavaScript `Map` object.
*
* Note: By design, the standard `Map` class already complies with the
* `AsyncTree` interface. This class adds some additional tree behavior, such as
* constructing subtree instances and setting their `parent` property. While
* we'd like to construct this by subclassing `Map`, that class appears
* puzzingly and deliberately implemented to break subclasses.
*
* @typedef {import("@weborigami/types").AsyncMutableTree} AsyncMutableTree
* @implements {AsyncMutableTree}
*/
export default class MapTree {
/**
* @param {Iterable} [iterable]
*/
constructor(iterable = []) {
this.map = new Map(iterable);
this.parent = null;
}
async get(key) {
// Try key as is
let value = this.map.get(key);
if (value === undefined) {
// Try the other variation of the key
const alternateKey = trailingSlash.toggle(key);
value = this.map.get(alternateKey);
if (value === undefined) {
// Key doesn't exist
return undefined;
}
}
value = await value;
if (value === undefined) {
// Key exists but value is undefined
return undefined;
}
setParent(value, this);
return value;
}
/** @returns {boolean} */
isSubtree(value) {
return Tree.isAsyncTree(value);
}
async keys() {
const keys = [];
for (const [key, value] of this.map.entries()) {
keys.push(trailingSlash.toggle(key, this.isSubtree(value)));
}
return keys;
}
async set(key, value) {
this.map.set(key, value);
return this;
}
}

View File

@@ -0,0 +1,142 @@
import { Tree } from "../internal.js";
import * as symbols from "../symbols.js";
import * as trailingSlash from "../trailingSlash.js";
import { getRealmObjectPrototype, setParent } from "../utilities.js";
/**
* A tree defined by a plain object or array.
*
* @typedef {import("@weborigami/types").AsyncMutableTree} AsyncMutableTree
* @implements {AsyncMutableTree}
*/
export default class ObjectTree {
/**
* Create a tree wrapping a given plain object or array.
*
* @param {any} object The object/array to wrap.
*/
constructor(object) {
this.object = object;
this.parent = object[symbols.parent] ?? null;
}
/**
* Return the value for the given key.
*
* @param {any} key
*/
async get(key) {
if (key == null) {
// Reject nullish key.
throw new ReferenceError(
`${this.constructor.name}: Cannot get a null or undefined key.`
);
}
// Does the object have the key with or without a trailing slash?
const existingKey = findExistingKey(this.object, key);
if (existingKey === null) {
// Key doesn't exist
return undefined;
}
let value = await this.object[existingKey];
if (value === undefined) {
// Key exists but value is undefined
return undefined;
}
setParent(value, this);
if (typeof value === "function" && !Object.hasOwn(this.object, key)) {
// Value is an inherited method; bind it to the object.
value = value.bind(this.object);
}
return value;
}
/** @returns {boolean} */
isSubtree(value) {
return Tree.isAsyncTree(value);
}
/**
* Enumerate the object's keys.
*/
async keys() {
// Walk up the prototype chain to realm's Object.prototype.
let obj = this.object;
const objectPrototype = getRealmObjectPrototype(obj);
const result = new Set();
while (obj && obj !== objectPrototype) {
// Get the enumerable instance properties and the get/set properties.
const descriptors = Object.getOwnPropertyDescriptors(obj);
const propertyNames = Object.entries(descriptors)
.filter(
([name, descriptor]) =>
name !== "constructor" &&
(descriptor.enumerable ||
(descriptor.get !== undefined && descriptor.set !== undefined))
)
.map(([name, descriptor]) =>
trailingSlash.has(name)
? // Preserve existing slash
name
: // Add a slash if the value is a plain property and a subtree
trailingSlash.toggle(
name,
descriptor.value !== undefined &&
this.isSubtree(descriptor.value)
)
);
for (const name of propertyNames) {
result.add(name);
}
obj = Object.getPrototypeOf(obj);
}
return result;
}
/**
* Set the value for the given key. If the value is undefined, delete the key.
*
* @param {any} key
* @param {any} value
*/
async set(key, value) {
const existingKey = findExistingKey(this.object, key);
if (value === undefined) {
// Delete the key if it exists.
if (existingKey !== null) {
delete this.object[existingKey];
}
} else {
// If the key exists under a different form, delete the existing key.
if (existingKey !== null && existingKey !== key) {
delete this.object[existingKey];
}
// Set the value for the key.
this.object[key] = value;
}
return this;
}
}
function findExistingKey(object, key) {
// First try key as is
if (key in object) {
return key;
}
// Try alternate form
const alternateKey = trailingSlash.toggle(key);
if (alternateKey in object) {
return alternateKey;
}
return null;
}

View File

@@ -0,0 +1,34 @@
import { setParent } from "../utilities.js";
/**
* A tree of Set objects.
*
* @typedef {import("@weborigami/types").AsyncTree} AsyncTree
* @implements {AsyncTree}
*/
export default class SetTree {
/**
* @param {Set} set
*/
constructor(set) {
this.values = Array.from(set);
this.parent = null;
}
async get(key) {
if (key == null) {
// Reject nullish key.
throw new ReferenceError(
`${this.constructor.name}: Cannot get a null or undefined key.`
);
}
const value = this.values[key];
setParent(value, this);
return value;
}
async keys() {
return this.values.keys();
}
}

View File

@@ -0,0 +1,123 @@
import * as trailingSlash from "../trailingSlash.js";
import { setParent } from "../utilities.js";
/**
* A tree of values obtained via HTTP/HTTPS calls. These values will be strings
* for HTTP responses with a MIME text type; otherwise they will be ArrayBuffer
* instances.
*
* @typedef {import("@weborigami/types").AsyncTree} AsyncTree
* @implements {AsyncTree}
*/
export default class SiteTree {
/**
* @param {string} href
*/
constructor(href = window?.location.href) {
if (href?.startsWith(".") && window?.location !== undefined) {
// URL represents a relative path; concatenate with current location.
href = new URL(href, window.location.href).href;
}
// Add trailing slash if not present; URL should represent a directory.
href = trailingSlash.add(href);
this.href = href;
this.parent = null;
}
/** @returns {Promise<any>} */
async get(key) {
if (key == null) {
// Reject nullish key.
throw new ReferenceError(
`${this.constructor.name}: Cannot get a null or undefined key.`
);
}
// A key with a trailing slash and no extension is for a folder; return a
// subtree without making a network request.
if (trailingSlash.has(key) && !key.includes(".")) {
const href = new URL(key, this.href).href;
const value = Reflect.construct(this.constructor, [href]);
setParent(value, this);
return value;
}
// HACK: For now we don't allow lookup of Origami extension handlers.
if (key.endsWith(".handler")) {
return undefined;
}
const href = new URL(key, this.href).href;
// Fetch the data at the given route.
let response;
try {
response = await fetch(href);
} catch (error) {
return undefined;
}
return this.processResponse(response);
}
/**
* Returns an empty set of keys.
*
* For a variation of `SiteTree` that can return the keys for a site route,
* see [ExplorableSiteTree](ExplorableSiteTree.html).
*
* @returns {Promise<Iterable<string>>}
*/
async keys() {
return [];
}
// Return true if the given media type is a standard text type.
static mediaTypeIsText(mediaType) {
if (!mediaType) {
return false;
}
const regex = /^(?<type>[^/]+)\/(?<subtype>[^;]+)/;
const match = mediaType.match(regex);
if (!match) {
return false;
}
const { type, subtype } = match.groups;
if (type === "text") {
return true;
}
return (
subtype === "json" ||
subtype.endsWith("+json") ||
subtype.endsWith(".json") ||
subtype === "xml" ||
subtype.endsWith("+xml") ||
subtype.endsWith(".xml")
);
}
get path() {
return this.href;
}
processResponse(response) {
if (!response.ok) {
return undefined;
}
const mediaType = response.headers?.get("Content-Type");
if (SiteTree.mediaTypeIsText(mediaType)) {
return response.text();
} else {
const buffer = response.arrayBuffer();
setParent(buffer, this);
return buffer;
}
}
get url() {
return new URL(this.href);
}
}

View File

@@ -0,0 +1,174 @@
import * as trailingSlash from "../trailingSlash.js";
/**
* Return a tree of years, months, and days from a start date to an end date.
*
* Both the start and end date can be provided in "YYYY-MM-DD", "YYYY-MM", or
* "YYYY" format. If a start year is provided, but a month is not, then the
* first month of the year will be used; if a start month is provided, but a day
* is not, then the first day of that month will be used. Similar logic applies
* to the end date, using the last month of the year or the last day of the
* month.
*
* If a start date is omitted, today will be used, likewise for the end date.
*
* @typedef {string|undefined} CalendarOptionsDate
* @typedef {( year: string, month: string, day: string ) => any} CalendarOptionsFn
* @param {{ end?: CalendarOptionsDate, start?: CalendarOptionsDate, value: CalendarOptionsFn }} options
*/
export default function calendarTree(options) {
const start = dateParts(options.start);
const end = dateParts(options.end);
const valueFn = options.value;
// Fill in the missing parts of the start and end dates.
const today = new Date();
if (start.day === undefined) {
start.day = start.year ? 1 : today.getDate();
}
if (start.month === undefined) {
start.month = start.year ? 1 : today.getMonth() + 1;
}
if (start.year === undefined) {
start.year = today.getFullYear();
}
if (end.day === undefined) {
end.day = end.month
? daysInMonth(end.year, end.month)
: end.year
? 31 // Last day of December
: today.getDate();
}
if (end.month === undefined) {
end.month = end.year ? 12 : today.getMonth() + 1;
}
if (end.year === undefined) {
end.year = today.getFullYear();
}
return yearsTree(start, end, valueFn);
}
function dateParts(date) {
let year;
let month;
let day;
if (typeof date === "string") {
const parts = date.split("-");
year = parts[0] ? parseInt(parts[0]) : undefined;
month = parts[1] ? parseInt(parts[1]) : undefined;
day = parts[2] ? parseInt(parts[2]) : undefined;
}
return { year, month, day };
}
function daysForMonthTree(year, month, start, end, valueFn) {
return {
async get(day) {
day = parseInt(trailingSlash.remove(day));
return this.inRange(day)
? valueFn(year.toString(), twoDigits(month), twoDigits(day))
: undefined;
},
inRange(day) {
if (year === start.year && year === end.year) {
if (month === start.month && month === end.month) {
return day >= start.day && day <= end.day;
} else if (month === start.month) {
return day >= start.day;
} else if (month === end.month) {
return day <= end.day;
} else {
return true;
}
} else if (year === start.year) {
if (month === start.month) {
return day >= start.day;
} else {
return month > start.month;
}
} else if (year === end.year) {
if (month === end.month) {
return day <= end.day;
} else {
return month < end.month;
}
} else {
return true;
}
},
async keys() {
const days = Array.from(
{ length: daysInMonth(year, month) },
(_, i) => i + 1
);
return days
.filter((day) => this.inRange(day))
.map((day) => twoDigits(day));
},
};
}
function daysInMonth(year, month) {
return new Date(year, month, 0).getDate();
}
function monthsForYearTree(year, start, end, valueFn) {
return {
async get(month) {
month = parseInt(trailingSlash.remove(month));
return this.inRange(month)
? daysForMonthTree(year, month, start, end, valueFn)
: undefined;
},
inRange(month) {
if (year === start.year && year === end.year) {
return month >= start.month && month <= end.month;
} else if (year === start.year) {
return month >= start.month;
} else if (year === end.year) {
return month <= end.month;
} else {
return true;
}
},
async keys() {
const months = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
return months
.filter((month) => this.inRange(month))
.map((month) => twoDigits(month));
},
};
}
function twoDigits(number) {
return number.toString().padStart(2, "0");
}
function yearsTree(start, end, valueFn) {
return {
async get(year) {
year = parseInt(trailingSlash.remove(year));
return this.inRange(year)
? monthsForYearTree(year, start, end, valueFn)
: undefined;
},
inRange(year) {
return year >= start.year && year <= end.year;
},
async keys() {
return Array.from(
{ length: end.year - start.year + 1 },
(_, i) => start.year + i
);
},
};
}

140
node_modules/@weborigami/async-tree/src/extension.js generated vendored Normal file
View File

@@ -0,0 +1,140 @@
import * as trailingSlash from "./trailingSlash.js";
import { isStringLike, toString } from "./utilities.js";
/**
* Replicate the logic of Node POSIX path.extname at
* https://github.com/nodejs/node/blob/main/lib/path.js so that we can use this
* in the browser.
*
* @param {string} path
* @returns {string}
*/
export function extname(path) {
if (typeof path !== "string") {
throw new TypeError(`Expected a string, got ${typeof path}`);
}
let startDot = -1;
let startPart = 0;
let end = -1;
let matchedSlash = true;
// Track the state of characters (if any) we see before our first dot and
// after any path separator we find
let preDotState = 0;
for (let i = path.length - 1; i >= 0; --i) {
const char = path[i];
if (char === "/") {
// If we reached a path separator that was not part of a set of path
// separators at the end of the string, stop now
if (!matchedSlash) {
startPart = i + 1;
break;
}
continue;
}
if (end === -1) {
// We saw the first non-path separator, mark this as the end of our
// extension
matchedSlash = false;
end = i + 1;
}
if (char === ".") {
// If this is our first dot, mark it as the start of our extension
if (startDot === -1) startDot = i;
else if (preDotState !== 1) preDotState = 1;
} else if (startDot !== -1) {
// We saw a non-dot and non-path separator before our dot, so we should
// have a good chance at having a non-empty extension
preDotState = -1;
}
}
if (
startDot === -1 ||
end === -1 ||
// We saw a non-dot character immediately before the dot
preDotState === 0 ||
// The (right-most) trimmed path component is exactly '..'
(preDotState === 1 && startDot === end - 1 && startDot === startPart + 1)
) {
return "";
}
return path.slice(startDot, end);
}
/**
* See if the key ends with the given extension. If it does, return the base
* name without the extension; if it doesn't return null.
*
* If the extension is empty, the key must not have an extension to match.
*
* If the extension is a slash, then the key must end with a slash for the match
* to succeed. Otherwise, a trailing slash in the key is ignored for purposes of
* comparison to comply with the way Origami can unpack files. Example: the keys
* "data.json" and "data.json/" are treated equally.
*
* This uses a different, more general interpretation of "extension" to mean any
* suffix, rather than Node's interpretation in `extname`. In particular, this
* will match a multi-part extension like ".foo.bar" that contains more than one
* dot.
*/
export function match(key, ext) {
if (!isStringLike(key)) {
return null;
}
key = toString(key);
if (ext === "/") {
return trailingSlash.has(key) ? trailingSlash.remove(key) : null;
}
// Key matches if it ends with the same extension
const normalized = trailingSlash.remove(key);
if (normalized.endsWith(ext)) {
const removed =
ext.length > 0 ? normalized.slice(0, -ext.length) : normalized;
return trailingSlash.toggle(removed, trailingSlash.has(key));
}
// Didn't match
return null;
}
/**
* If the given key ends in the source extension (which will generally include a
* period), replace that extension with the result extension (which again should
* generally include a period). Otherwise, return the key as is.
*
* If the key ends in a trailing slash, that will be preserved in the result.
* Exception: if the source extension is empty, and the key doesn't have an
* extension, the result extension will be appended to the key without a slash.
*
* @param {string} key
* @param {string} sourceExtension
* @param {string} resultExtension
*/
export function replace(key, sourceExtension, resultExtension) {
if (!isStringLike(key)) {
return null;
}
key = toString(key);
if (!match(key, sourceExtension)) {
return key;
}
let replaced;
const normalizedKey = trailingSlash.remove(key);
if (sourceExtension === "") {
replaced = normalizedKey + resultExtension;
if (!normalizedKey.includes(".")) {
return replaced;
}
} else if (sourceExtension === "/") {
return trailingSlash.remove(key) + resultExtension;
} else {
replaced =
normalizedKey.slice(0, -sourceExtension.length) + resultExtension;
}
return trailingSlash.toggle(replaced, trailingSlash.has(key));
}

16
node_modules/@weborigami/async-tree/src/internal.js generated vendored Normal file
View File

@@ -0,0 +1,16 @@
//
// This library includes a number of modules with circular dependencies. This
// module exists to explicitly set the loading order for those modules. To
// enforce use of this loading order, other modules should only load the modules
// below via this module.
//
// About this pattern:
// https://medium.com/visual-development/how-to-fix-nasty-circular-dependency-issues-once-and-for-all-in-javascript-typescript-a04c987cf0de
//
// Note: to avoid having VS Code auto-sort the imports, keep lines between them.
export * as Tree from "./Tree.js";
export { default as ObjectTree } from "./drivers/ObjectTree.js";
export { default as DeepObjectTree } from "./drivers/DeepObjectTree.js";

View File

@@ -0,0 +1,4 @@
import { Treelike } from "../index.ts";
export function parse(json: string): any;
export function stringify(treelike: Treelike): Promise<string>;

23
node_modules/@weborigami/async-tree/src/jsonKeys.js generated vendored Normal file
View File

@@ -0,0 +1,23 @@
import { Tree } from "./internal.js";
/**
* The JSON Keys protocol lets a site expose the keys of a node in the site so
* that they can be read by SiteTree.
*
* This file format is a JSON array of key descriptors: a string like
* "index.html" for a specific resource available at the node, or a string with
* a trailing slash like "about/" for a subtree of that node.
*/
/**
* Given a tree node, return a JSON string that can be written to a .keys.json
* file.
*/
export async function stringify(treelike) {
const tree = Tree.from(treelike);
let keys = Array.from(await tree.keys());
// Skip the key `.keys.json` if present.
keys = keys.filter((key) => key !== ".keys.json");
const json = JSON.stringify(keys);
return json;
}

View File

@@ -0,0 +1,98 @@
import { ObjectTree, Tree } from "../internal.js";
/**
* Caches values from a source tree in a second cache tree. Cache source tree
* keys in memory.
*
* If no second tree is supplied, an in-memory value cache is used.
*
* An optional third filter tree can be supplied. If a filter tree is supplied,
* only values for keys that match the filter will be cached.
*
* @typedef {import("@weborigami/types").AsyncTree} AsyncTree
* @typedef {import("@weborigami/types").AsyncMutableTree} AsyncMutableTree
* @typedef {import("../../index.ts").Treelike} Treelike
*
* @param {Treelike} sourceTreelike
* @param {AsyncMutableTree} [cacheTreelike]
* @param {Treelike} [filterTreelike]
* @returns {AsyncTree & { description: string }}
*/
export default function treeCache(
sourceTreelike,
cacheTreelike,
filterTreelike
) {
if (!sourceTreelike) {
const error = new TypeError(`cache: The source tree isn't defined.`);
/** @type {any} */ (error).position = 0;
throw error;
}
const source = Tree.from(sourceTreelike);
const filter = filterTreelike ? Tree.from(filterTreelike) : undefined;
/** @type {AsyncMutableTree} */
let cache;
if (cacheTreelike) {
// @ts-ignore
cache = Tree.from(cacheTreelike);
if (!Tree.isAsyncMutableTree(cache)) {
throw new Error("Cache tree must define a set() method.");
}
} else {
cache = new ObjectTree({});
}
let keys;
return {
description: "cache",
async get(key) {
// Check cache tree first.
let cacheValue = await cache.get(key);
if (cacheValue !== undefined && !Tree.isAsyncTree(cacheValue)) {
// Leaf node cache hit
return cacheValue;
}
// Cache miss or interior node cache hit.
let value = await source.get(key);
if (value !== undefined) {
// If a filter is defined, does the key match the filter?
const filterValue = filter ? await filter.get(key) : undefined;
const filterMatch = !filter || filterValue !== undefined;
if (filterMatch) {
if (Tree.isAsyncTree(value)) {
// Construct merged tree for a tree result.
if (cacheValue === undefined) {
// Construct new empty container in cache
await cache.set(key, {});
cacheValue = await cache.get(key);
if (!Tree.isAsyncTree(cacheValue)) {
// Coerce to tree and then save it back to the cache. This is
// necessary, e.g., if cache is an ObjectTree; we want the
// subtree to also be an ObjectTree, not a plain object.
cacheValue = Tree.from(cacheValue);
await cache.set(key, cacheValue);
}
}
value = treeCache(value, cacheValue, filterValue);
} else {
// Save in cache before returning.
await cache.set(key, value);
}
}
return value;
}
return undefined;
},
async keys() {
keys ??= await source.keys();
return keys;
},
};
}

View File

@@ -0,0 +1,124 @@
import * as trailingSlash from "../trailingSlash.js";
const treeToCaches = new WeakMap();
/**
* Given a key function, return a new key function and inverse key function that
* cache the results of the original.
*
* If `skipSubtrees` is true, the inverse key function will skip any source keys
* that are keys for subtrees, returning the source key unmodified.
*
* @typedef {import("../../index.ts").KeyFn} KeyFn
*
* @param {KeyFn} keyFn
* @param {boolean?} skipSubtrees
* @returns {{ key: KeyFn, inverseKey: KeyFn }}
*/
export default function cachedKeyFunctions(keyFn, skipSubtrees = false) {
return {
async inverseKey(resultKey, tree) {
const { resultKeyToSourceKey, sourceKeyToResultKey } =
getKeyMapsForTree(tree);
const cachedSourceKey = searchKeyMap(resultKeyToSourceKey, resultKey);
if (cachedSourceKey !== undefined) {
return cachedSourceKey;
}
// Iterate through the tree's keys, calculating source keys as we go,
// until we find a match. Cache all the intermediate results and the
// final match. This is O(n), but we stop as soon as we find a match,
// and subsequent calls will benefit from the intermediate results.
const resultKeyWithoutSlash = trailingSlash.remove(resultKey);
for (const sourceKey of await tree.keys()) {
// Skip any source keys we already know about.
if (sourceKeyToResultKey.has(sourceKey)) {
continue;
}
const computedResultKey = await computeAndCacheResultKey(
tree,
keyFn,
skipSubtrees,
sourceKey
);
if (
computedResultKey &&
trailingSlash.remove(computedResultKey) === resultKeyWithoutSlash
) {
// Match found, match trailing slash and return
return trailingSlash.toggle(sourceKey, trailingSlash.has(resultKey));
}
}
return undefined;
},
async key(sourceKey, tree) {
const { sourceKeyToResultKey } = getKeyMapsForTree(tree);
const cachedResultKey = searchKeyMap(sourceKeyToResultKey, sourceKey);
if (cachedResultKey !== undefined) {
return cachedResultKey;
}
const resultKey = await computeAndCacheResultKey(
tree,
keyFn,
skipSubtrees,
sourceKey
);
return resultKey;
},
};
}
async function computeAndCacheResultKey(tree, keyFn, skipSubtrees, sourceKey) {
const { resultKeyToSourceKey, sourceKeyToResultKey } =
getKeyMapsForTree(tree);
const resultKey =
skipSubtrees && trailingSlash.has(sourceKey)
? sourceKey
: await keyFn(sourceKey, tree);
sourceKeyToResultKey.set(sourceKey, resultKey);
resultKeyToSourceKey.set(resultKey, sourceKey);
return resultKey;
}
// Maintain key->inverseKey and inverseKey->key mappings for each tree. These
// store subtree keys in either direction with a trailing slash.
function getKeyMapsForTree(tree) {
let keyMaps = treeToCaches.get(tree);
if (!keyMaps) {
keyMaps = {
resultKeyToSourceKey: new Map(),
sourceKeyToResultKey: new Map(),
};
treeToCaches.set(tree, keyMaps);
}
return keyMaps;
}
// Search the given key map for the key. Ignore trailing slashes in the search,
// but preserve them in the result.
function searchKeyMap(keyMap, key) {
// Check key as is
let match;
if (keyMap.has(key)) {
match = keyMap.get(key);
} else if (!trailingSlash.has(key)) {
// Check key without trailing slash
const withSlash = trailingSlash.add(key);
if (keyMap.has(withSlash)) {
match = keyMap.get(withSlash);
}
}
return match
? trailingSlash.toggle(match, trailingSlash.has(key))
: undefined;
}

View File

@@ -0,0 +1,34 @@
import { toString } from "../utilities.js";
import deepValuesIterator from "./deepValuesIterator.js";
/**
* Concatenate the deep text values in a tree.
*
* @typedef {import("@weborigami/types").AsyncTree} AsyncTree
*
* @this {AsyncTree|null}
* @param {import("../../index.ts").Treelike} treelike
*/
export default async function concatTreeValues(treelike) {
if (!treelike) {
const error = new TypeError(`concat: The tree isn't defined.`);
/** @type {any} */ (error).position = 0;
throw error;
}
const strings = [];
for await (const value of deepValuesIterator(treelike, { expand: true })) {
let string;
if (value === null) {
console.warn("Warning: Origami template encountered a null value");
string = "null";
} else if (value === undefined) {
console.warn("Warning: Origami template encountered an undefined value");
string = "undefined";
} else {
string = toString(value);
}
strings.push(string);
}
return strings.join("");
}

View File

@@ -0,0 +1,77 @@
import { Tree } from "../internal.js";
import * as trailingSlash from "../trailingSlash.js";
/**
* Return a tree that performs a deep merge of the given trees.
*
* @typedef {import("@weborigami/types").AsyncTree} AsyncTree
* @param {import("../../index.ts").Treelike[]} sources
* @returns {AsyncTree & { description: string }}
*/
export default function deepMerge(...sources) {
let trees = sources.map((treelike) => Tree.from(treelike, { deep: true }));
let mergeParent;
return {
description: "deepMerge",
async get(key) {
const subtrees = [];
// Check trees for the indicated key in reverse order.
for (let index = trees.length - 1; index >= 0; index--) {
const tree = trees[index];
const value = await tree.get(key);
if (Tree.isAsyncTree(value)) {
if (value.parent === tree) {
// Merged tree acts as parent instead of the source tree.
value.parent = this;
}
subtrees.unshift(value);
} else if (value !== undefined) {
return value;
}
}
if (subtrees.length > 1) {
const merged = deepMerge(...subtrees);
merged.parent = this;
return merged;
} else if (subtrees.length === 1) {
return subtrees[0];
} else {
return undefined;
}
},
async keys() {
const keys = new Set();
// Collect keys in the order the trees were provided.
for (const tree of trees) {
for (const key of await tree.keys()) {
// Remove the alternate form of the key (if it exists)
const alternateKey = trailingSlash.toggle(key);
if (alternateKey !== key) {
keys.delete(alternateKey);
}
keys.add(key);
}
}
return keys;
},
get parent() {
return mergeParent;
},
set parent(parent) {
mergeParent = parent;
trees = sources.map((treelike) => {
const tree = Tree.isAsyncTree(treelike)
? Object.create(treelike)
: Tree.from(treelike);
tree.parent = parent;
return tree;
});
},
};
}

View File

@@ -0,0 +1,37 @@
import { Tree } from "../internal.js";
/**
* Reverse the order of keys at all levels of the tree.
*
* @typedef {import("@weborigami/types").AsyncTree} AsyncTree
* @typedef {import("../../index.ts").Treelike} Treelike
*
* @param {Treelike} treelike
* @returns {AsyncTree}
*/
export default function deepReverse(treelike) {
if (!treelike) {
const error = new TypeError(
`deepReverse: The tree to reverse isn't defined.`
);
/** @type {any} */ (error).position = 0;
throw error;
}
const tree = Tree.from(treelike, { deep: true });
return {
async get(key) {
let value = await tree.get(key);
if (Tree.isAsyncTree(value)) {
value = deepReverse(value);
}
return value;
},
async keys() {
const keys = Array.from(await tree.keys());
keys.reverse();
return keys;
},
};
}

View File

@@ -0,0 +1,42 @@
import { Tree } from "../internal.js";
/**
* Returns a function that traverses a tree deeply and returns the values of the
* first `count` keys.
*
* This is similar to `deepValues`, but it is more efficient for large trees as
* stops after `count` values.
*
* @param {import("../../index.ts").Treelike} treelike
* @param {number} count
*/
export default async function deepTake(treelike, count) {
if (!treelike) {
const error = new TypeError(`deepTake: The tree isn't defined.`);
/** @type {any} */ (error).position = 0;
throw error;
}
const tree = await Tree.from(treelike, { deep: true });
const { values } = await traverse(tree, count);
return Tree.from(values, { deep: true });
}
async function traverse(tree, count) {
const values = [];
for (const key of await tree.keys()) {
if (count <= 0) {
break;
}
let value = await tree.get(key);
if (Tree.isAsyncTree(value)) {
const traversed = await traverse(value, count);
values.push(...traversed.values);
count = traversed.count;
} else {
values.push(value);
count--;
}
}
return { count, values };
}

View File

@@ -0,0 +1,19 @@
import deepValuesIterator from "./deepValuesIterator.js";
/**
* Return the in-order exterior values of a tree as a flat array.
*
* @param {import("../../index.ts").Treelike} treelike
* @param {{ expand?: boolean }} [options]
*/
export default async function deepValues(
treelike,
options = { expand: false }
) {
const iterator = deepValuesIterator(treelike, options);
const values = [];
for await (const value of iterator) {
values.push(value);
}
return values;
}

View File

@@ -0,0 +1,37 @@
import { Tree } from "../internal.js";
/**
* Return an iterator that yields all values in a tree, including nested trees.
*
* If the `expand` option is true, treelike values (but not functions) will be
* expanded into nested trees and their values will be yielded.
*
* @param {import("../../index.ts").Treelike} treelike
* @param {{ expand?: boolean }} [options]
* @returns {AsyncGenerator<any, void, undefined>}
*/
export default async function* deepValuesIterator(
treelike,
options = { expand: false }
) {
if (!treelike) {
const error = new TypeError(`deepValues: The tree isn't defined.`);
/** @type {any} */ (error).position = 0;
throw error;
}
const tree = Tree.from(treelike, { deep: true });
for (const key of await tree.keys()) {
let value = await tree.get(key);
// Recurse into child trees, but don't expand functions.
const recurse =
Tree.isAsyncTree(value) ||
(options.expand && typeof value !== "function" && Tree.isTreelike(value));
if (recurse) {
yield* deepValuesIterator(value, options);
} else {
yield value;
}
}
}

View File

@@ -0,0 +1,53 @@
import { ObjectTree, Tree } from "../internal.js";
/**
* Given a function that returns a grouping key for a value, returns a transform
* that applies that grouping function to a tree.
*
* @param {import("../../index.ts").Treelike} treelike
* @param {import("../../index.ts").ValueKeyFn} groupKeyFn
*/
export default async function group(treelike, groupKeyFn) {
if (!treelike) {
const error = new TypeError(`groupBy: The tree to group isn't defined.`);
/** @type {any} */ (error).position = 0;
throw error;
}
const tree = Tree.from(treelike);
const keys = Array.from(await tree.keys());
// Are all the keys integers?
const isArray = keys.every((key) => !Number.isNaN(parseInt(key)));
const result = {};
for (const key of await tree.keys()) {
const value = await tree.get(key);
// Get the groups for this value.
let groups = await groupKeyFn(value, key, tree);
if (!groups) {
continue;
}
if (!Tree.isTreelike(groups)) {
// A single value was returned
groups = [groups];
}
groups = Tree.from(groups);
// Add the value to each group.
for (const group of await Tree.values(groups)) {
if (isArray) {
result[group] ??= [];
result[group].push(value);
} else {
result[group] ??= {};
result[group][key] = value;
}
}
}
return new ObjectTree(result);
}

View File

@@ -0,0 +1,20 @@
import { Tree } from "../internal.js";
export default function invokeFunctions(treelike) {
const tree = Tree.from(treelike);
return {
async get(key) {
let value = await tree.get(key);
if (typeof value === "function") {
value = value();
} else if (Tree.isAsyncTree(value)) {
value = invokeFunctions(value);
}
return value;
},
async keys() {
return tree.keys();
},
};
}

View File

@@ -0,0 +1,48 @@
import * as extension from "../extension.js";
import * as trailingSlash from "../trailingSlash.js";
/**
* Given a source resultExtension and a result resultExtension, return a pair of key
* functions that map between them.
*
* The resulting `inverseKey` and `key` functions are compatible with those
* expected by map and other transforms.
*
* @typedef {import("@weborigami/types").AsyncTree} AsyncTree
* @param {{ resultExtension?: string, sourceExtension: string }}
* options
*/
export default function keyFunctionsForExtensions({
resultExtension,
sourceExtension,
}) {
if (resultExtension === undefined) {
resultExtension = sourceExtension;
}
checkDeprecatedExtensionWithoutDot(resultExtension);
checkDeprecatedExtensionWithoutDot(sourceExtension);
return {
async inverseKey(resultKey, tree) {
// Remove trailing slash so that mapFn won't inadvertently unpack files.
const baseKey = trailingSlash.remove(resultKey);
const basename = extension.match(baseKey, resultExtension);
return basename ? `${basename}${sourceExtension}` : undefined;
},
async key(sourceKey, tree) {
return extension.match(sourceKey, sourceExtension)
? extension.replace(sourceKey, sourceExtension, resultExtension)
: undefined;
},
};
}
function checkDeprecatedExtensionWithoutDot(extension) {
if (extension && extension !== "/" && !extension.startsWith(".")) {
throw new RangeError(
`map: Warning: the extension "${extension}" should start with a period.`
);
}
}

View File

@@ -0,0 +1,129 @@
import { Tree } from "../internal.js";
import * as trailingSlash from "../trailingSlash.js";
/**
* Transform the keys and/or values of a tree.
*
* @typedef {import("../../index.ts").KeyFn} KeyFn
* @typedef {import("../../index.ts").TreeMapOptions} MapOptions
* @typedef {import("../../index.ts").ValueKeyFn} ValueKeyFn
*
* @param {import("../../index.ts").Treelike} treelike
* @param {MapOptions|ValueKeyFn} options
*/
export default function map(treelike, options = {}) {
let deep;
let description;
let inverseKeyFn;
let keyFn;
let needsSourceValue;
let valueFn;
if (!treelike) {
const error = new TypeError(`map: The tree to map isn't defined.`);
/** @type {any} */ (error).position = 0;
throw error;
}
if (typeof options === "function") {
// Take the single function argument as the valueFn
valueFn = options;
} else {
deep = options.deep;
description = options.description;
inverseKeyFn = options.inverseKey;
keyFn = options.key;
needsSourceValue = options.needsSourceValue;
valueFn = options.value;
}
deep ??= false;
description ??= "key/value map";
// @ts-ignore
inverseKeyFn ??= valueFn?.inverseKey;
// @ts-ignore
keyFn ??= valueFn?.key;
needsSourceValue ??= true;
if ((keyFn && !inverseKeyFn) || (!keyFn && inverseKeyFn)) {
throw new TypeError(
`map: You must specify both key and inverseKey functions, or neither.`
);
}
/**
* @param {import("@weborigami/types").AsyncTree} tree
*/
function mapFn(tree) {
// The transformed tree is actually an extension of the original tree's
// prototype chain. This allows the transformed tree to inherit any
// properties/methods. For example, the `parent` of the transformed tree is
// the original tree's parent.
const transformed = Object.create(tree);
transformed.description = description;
if (keyFn || valueFn) {
transformed.get = async (resultKey) => {
// Step 1: Map the result key to the source key.
const sourceKey = (await inverseKeyFn?.(resultKey, tree)) ?? resultKey;
if (sourceKey === undefined) {
// No source key means no value.
return undefined;
}
// Step 2: Get the source value.
let sourceValue;
if (needsSourceValue) {
// Normal case: get the value from the source tree.
sourceValue = await tree.get(sourceKey);
} else if (deep && trailingSlash.has(sourceKey)) {
// Only get the source value if it's expected to be a subtree.
sourceValue = tree;
}
// Step 3: Map the source value to the result value.
let resultValue;
if (needsSourceValue && sourceValue === undefined) {
// No source value means no result value.
resultValue = undefined;
} else if (deep && Tree.isAsyncTree(sourceValue)) {
// Map a subtree.
resultValue = mapFn(sourceValue);
} else if (valueFn) {
// Map a single value.
resultValue = await valueFn(sourceValue, sourceKey, tree);
} else {
// Return source value as is.
resultValue = sourceValue;
}
return resultValue;
};
}
if (keyFn) {
transformed.keys = async () => {
// Apply the keyFn to source keys for leaf values (not subtrees).
const sourceKeys = Array.from(await tree.keys());
const mapped = await Promise.all(
sourceKeys.map(async (sourceKey) =>
// Deep maps leave source keys for subtrees alone
deep && trailingSlash.has(sourceKey)
? sourceKey
: await keyFn(sourceKey, tree)
)
);
// Filter out any cases where the keyFn returned undefined.
const resultKeys = mapped.filter((key) => key !== undefined);
return resultKeys;
};
}
return transformed;
}
const tree = Tree.from(treelike, { deep });
return mapFn(tree);
}

View File

@@ -0,0 +1,65 @@
import { Tree } from "../internal.js";
import * as symbols from "../symbols.js";
import * as trailingSlash from "../trailingSlash.js";
/**
* Return a tree that performs a shallow merge of the given trees.
*
* Given a set of trees, the `get` method looks at each tree in turn. The first
* tree is asked for the value with the key. If an tree returns a defined value
* (i.e., not undefined), that value is returned. If the first tree returns
* undefined, the second tree will be asked, and so on. If none of the trees
* return a defined value, the `get` method returns undefined.
*
* @typedef {import("@weborigami/types").AsyncTree} AsyncTree
* @param {import("../../index.ts").Treelike[]} sources
* @returns {AsyncTree & { description: string, trees: AsyncTree[]}}
*/
export default function merge(...sources) {
const trees = sources.map((treelike) => Tree.from(treelike));
return {
description: "merge",
async get(key) {
// Check trees for the indicated key in reverse order.
for (let index = trees.length - 1; index >= 0; index--) {
const tree = trees[index];
const value = await tree.get(key);
if (value !== undefined) {
// Merged tree acts as parent instead of the source tree.
if (Tree.isAsyncTree(value) && value.parent === tree) {
value.parent = this;
} else if (
typeof value === "object" &&
value?.[symbols.parent] === tree
) {
value[symbols.parent] = this;
}
return value;
}
}
return undefined;
},
async keys() {
const keys = new Set();
// Collect keys in the order the trees were provided.
for (const tree of trees) {
for (const key of await tree.keys()) {
// Remove the alternate form of the key (if it exists)
const alternateKey = trailingSlash.toggle(key);
if (alternateKey !== key) {
keys.delete(alternateKey);
}
keys.add(key);
}
}
return keys;
},
get trees() {
return trees;
},
};
}

View File

@@ -0,0 +1,88 @@
import { Tree } from "../internal.js";
import * as trailingSlash from "../trailingSlash.js";
/**
* A tree whose keys are strings interpreted as regular expressions.
*
* Requests to `get` a key are matched against the regular expressions, and the
* value for the first matching key is returned. The regular expresions are
* taken to match the entire key -- if they do not already start and end with
* `^` and `$` respectively, those are added.
*
* @type {import("../../index.ts").TreeTransform}
*/
export default async function regExpKeys(treelike) {
if (!treelike) {
const error = new TypeError(
`regExpKeys: The tree of regular expressions isn't defined.`
);
/** @type {any} */ (error).position = 0;
throw error;
}
const tree = Tree.from(treelike);
const map = new Map();
// We build the output tree first so that we can refer to it when setting
// `parent` on subtrees below.
let result = {
// @ts-ignore
description: "regExpKeys",
async get(key) {
if (key == null) {
// Reject nullish key.
throw new ReferenceError(
`${this.constructor.name}: Cannot get a null or undefined key.`
);
}
for (const [regExp, value] of map) {
if (regExp.test(key)) {
return value;
}
}
return undefined;
},
async keys() {
return map.keys();
},
};
// Turn the input tree's string keys into regular expressions, then map those
// to the corresponding values.
for (const key of await tree.keys()) {
if (typeof key !== "string") {
// Skip non-string keys.
continue;
}
// Get value.
let value = await tree.get(key);
let regExp;
if (trailingSlash.has(key) || Tree.isAsyncTree(value)) {
const baseKey = trailingSlash.remove(key);
regExp = new RegExp("^" + baseKey + "/?$");
// Subtree
value = regExpKeys(value);
if (!value.parent) {
value.parent = result;
}
} else {
// Construct regular expression.
let text = key;
if (!text.startsWith("^")) {
text = "^" + text;
}
if (!text.endsWith("$")) {
text = text + "$";
}
regExp = new RegExp(text);
}
map.set(regExp, value);
}
return result;
}

View File

@@ -0,0 +1,31 @@
import { Tree } from "../internal.js";
/**
* Reverse the order of the top-level keys in the tree.
*
* @typedef {import("@weborigami/types").AsyncTree} AsyncTree
* @typedef {import("../../index.ts").Treelike} Treelike
*
* @param {Treelike} treelike
* @returns {AsyncTree}
*/
export default function reverse(treelike) {
if (!treelike) {
const error = new TypeError(`reverse: The tree to reverse isn't defined.`);
/** @type {any} */ (error).position = 0;
throw error;
}
const tree = Tree.from(treelike);
return {
async get(key) {
return tree.get(key);
},
async keys() {
const keys = Array.from(await tree.keys());
keys.reverse();
return keys;
},
};
}

View File

@@ -0,0 +1,71 @@
import { Tree } from "../internal.js";
/**
* A tree's "scope" is the collection of everything in that tree and all of its
* ancestors.
*
* @typedef {import("@weborigami/types").AsyncTree} AsyncTree
* @typedef {import("../../index.ts").Treelike} Treelike
*
* @param {Treelike} treelike
* @returns {AsyncTree & {trees: AsyncTree[]}}
*/
export default function scope(treelike) {
if (!treelike) {
const error = new TypeError(`scope: The tree isn't defined.`);
/** @type {any} */ (error).position = 0;
throw error;
}
const tree = Tree.from(treelike);
return {
// Starting with this tree, search up the parent hierarchy.
async get(key) {
/** @type {AsyncTree|null|undefined} */
let current = tree;
let value;
while (current) {
value = await current.get(key);
if (value !== undefined) {
break;
}
current = current.parent;
}
return value;
},
// Collect all keys for this tree and all parents
async keys() {
const keys = new Set();
/** @type {AsyncTree|null|undefined} */
let current = tree;
while (current) {
for (const key of await current.keys()) {
keys.add(key);
}
current = current.parent;
}
return keys;
},
// Collect all keys for this tree and all parents.
//
// This method exists for debugging purposes, as it's helpful to be able to
// quickly flatten and view the entire scope chain.
get trees() {
const result = [];
/** @type {AsyncTree|null|undefined} */
let current = tree;
while (current) {
result.push(current);
current = current.parent;
}
return result;
},
};
}

View File

@@ -0,0 +1,61 @@
import { Tree } from "../internal.js";
/**
* Return a new tree with the original's keys sorted. A comparison function can
* be provided; by default the keys will be sorted in [natural sort
* order](https://en.wikipedia.org/wiki/Natural_sort_order).
*
* @typedef {import("@weborigami/types").AsyncTree} AsyncTree
* @typedef {(key: any, tree: AsyncTree) => any} SortKeyFn
* @typedef {{ compare?: (a: any, b: any) => number, sortKey?: SortKeyFn }}
* SortOptions
*
* @param {import("../../index.ts").Treelike} treelike
* @param {SortOptions} [options]
*/
export default function sort(treelike, options) {
if (!treelike) {
const error = new TypeError(`sort: The tree to sort isn't defined.`);
/** @type {any} */ (error).position = 0;
throw error;
}
const sortKey = options?.sortKey;
let compare = options?.compare;
const tree = Tree.from(treelike);
const transformed = Object.create(tree);
transformed.keys = async () => {
const keys = Array.from(await tree.keys());
if (sortKey) {
// Invoke the async sortKey function to get sort keys.
// Create { key, sortKey } tuples.
const tuples = await Promise.all(
keys.map(async (key) => {
const sort = await sortKey(key, tree);
if (sort === undefined) {
throw new Error(
`sortKey function returned undefined for key ${key}`
);
}
return { key, sort };
})
);
// Wrap the comparison function so it applies to sort keys.
const defaultCompare = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
const originalCompare = compare ?? defaultCompare;
// Sort by the sort key.
tuples.sort((a, b) => originalCompare(a.sort, b.sort));
// Map back to the original keys.
const sorted = tuples.map((pair) => pair.key);
return sorted;
} else {
// Use original keys as sort keys.
// If compare is undefined, this uses default sort order.
return keys.slice().sort(compare);
}
};
return transformed;
}

View File

@@ -0,0 +1,28 @@
import { Tree } from "../internal.js";
/**
* Returns a new tree with the number of keys limited to the indicated count.
*
* @param {import("../../index.ts").Treelike} treelike
* @param {number} count
*/
export default function take(treelike, count) {
if (!treelike) {
const error = new TypeError(`take: The tree to take from isn't defined.`);
/** @type {any} */ (error).position = 0;
throw error;
}
const tree = Tree.from(treelike);
return {
async keys() {
const keys = Array.from(await tree.keys());
return keys.slice(0, count);
},
async get(key) {
return tree.get(key);
},
};
}

2
node_modules/@weborigami/async-tree/src/symbols.js generated vendored Normal file
View File

@@ -0,0 +1,2 @@
export const deep = Symbol("deep");
export const parent = Symbol("parent");

View File

@@ -0,0 +1,54 @@
/**
* Add a trailing slash to a string key if the value is truthy. If the key
* is not a string, it will be returned as is.
*
* @param {any} key
*/
export function add(key) {
if (key == null) {
throw new ReferenceError("trailingSlash: key was undefined");
}
return typeof key === "string" && !key.endsWith("/") ? `${key}/` : key;
}
/**
* Return true if the indicated key is a string with a trailing slash,
* false otherwise.
*
* @param {any} key
*/
export function has(key) {
if (key == null) {
throw new ReferenceError("trailingSlash: key was undefined");
}
return typeof key === "string" && key.endsWith("/");
}
/**
* Remove a trailing slash from a string key.
*
* @param {any} key
*/
export function remove(key) {
if (key == null) {
throw new ReferenceError("trailingSlash: key was undefined");
}
return typeof key === "string" ? key.replace(/\/$/, "") : key;
}
/**
* If the key has a trailing slash, remove it; otherwise add it.
*
* @param {any} key
* @param {boolean} [force]
*/
export function toggle(key, force = undefined) {
if (key == null) {
throw new ReferenceError("trailingSlash: key was undefined");
}
if (typeof key !== "string") {
return key;
}
const addSlash = force ?? !has(key);
return addSlash ? add(key) : remove(key);
}

18
node_modules/@weborigami/async-tree/src/utilities.d.ts generated vendored Normal file
View File

@@ -0,0 +1,18 @@
import { AsyncTree } from "@weborigami/types";
import { Packed, PlainObject, StringLike } from "../index.ts";
export function box(value: any): any;
export function castArrayLike(keys: any[], values: any[]): any;
export function getRealmObjectPrototype(object: any): any;
export const hiddenFileNames: string[];
export function isPacked(obj: any): obj is Packed;
export function isPlainObject(obj: any): obj is PlainObject;
export function isStringLike(obj: any): obj is StringLike;
export function isUnpackable(obj): obj is { unpack: () => any };
export function keysFromPath(path: string): string[];
export const naturalOrder: (a: string, b: string) => number;
export function pathFromKeys(keys: string[]): string;
export function pipeline(start: any, ...functions: Function[]): Promise<any>;
export function setParent(child: any, parent: AsyncTree): void;
export function toPlainValue(object: any): Promise<any>;
export function toString(object: any): string;

374
node_modules/@weborigami/async-tree/src/utilities.js generated vendored Normal file
View File

@@ -0,0 +1,374 @@
import { Tree } from "./internal.js";
import * as symbols from "./symbols.js";
import * as trailingSlash from "./trailingSlash.js";
const textDecoder = new TextDecoder();
const TypedArray = Object.getPrototypeOf(Uint8Array);
/**
* Return the value as an object. If the value is already an object it will be
* returned as is. If the value is a primitive, it will be wrapped in an object:
* a string will be wrapped in a String object, a number will be wrapped in a
* Number object, and a boolean will be wrapped in a Boolean object.
*
* @param {any} value
*/
export function box(value) {
switch (typeof value) {
case "string":
return new String(value);
case "number":
return new Number(value);
case "boolean":
return new Boolean(value);
default:
return value;
}
}
/**
* Create an array or plain object from the given keys and values.
*
* If the given plain object has only sequential integer keys, return the
* values as an array. Otherwise, create a plain object with the keys and
* values.
*
* @param {any[]} keys
* @param {any[]} values
*/
export function castArrayLike(keys, values) {
let isArrayLike = false;
// Need at least one key to count as an array
if (keys.length > 0) {
// Assume it's an array
isArrayLike = true;
// Then check if all the keys are sequential integers
let expectedIndex = 0;
for (const key of keys) {
const index = Number(key);
if (key === "" || isNaN(index) || index !== expectedIndex) {
// Not array-like
isArrayLike = false;
break;
}
expectedIndex++;
}
}
return isArrayLike
? values
: Object.fromEntries(keys.map((key, i) => [key, values[i]]));
}
/**
* Return the Object prototype at the root of the object's prototype chain.
*
* This is used by functions like isPlainObject() to handle cases where the
* `Object` at the root prototype chain is in a different realm.
*
* @param {any} object
*/
export function getRealmObjectPrototype(object) {
if (Object.getPrototypeOf(object) === null) {
// The object has no prototype.
return null;
}
let proto = object;
while (Object.getPrototypeOf(proto) !== null) {
proto = Object.getPrototypeOf(proto);
}
return proto;
}
// Names of OS-generated files that should not be enumerated
export const hiddenFileNames = [".DS_Store"];
/**
* Return true if the object is in a packed form (or can be readily packed into
* a form) that can be given to fs.writeFile or response.write().
*
* @param {any} obj
* @returns {obj is import("../index.ts").Packed}
*/
export function isPacked(obj) {
return (
typeof obj === "string" ||
obj instanceof ArrayBuffer ||
obj instanceof ReadableStream ||
obj instanceof String ||
obj instanceof TypedArray
);
}
/**
* Return true if the object is a plain JavaScript object created by `{}`,
* `new Object()`, or `Object.create(null)`.
*
* This function also considers object-like things with no prototype (like a
* `Module`) as plain objects.
*
* @param {any} obj
* @returns {obj is import("../index.ts").PlainObject}
*/
export function isPlainObject(obj) {
// From https://stackoverflow.com/q/51722354/76472
if (typeof obj !== "object" || obj === null) {
return false;
}
// We treat object-like things with no prototype (like a Module) as plain
// objects.
if (Object.getPrototypeOf(obj) === null) {
return true;
}
// Do we inherit directly from Object in this realm?
return Object.getPrototypeOf(obj) === getRealmObjectPrototype(obj);
}
/**
* Return true if the value is a primitive JavaScript value.
*
* @param {any} value
*/
export function isPrimitive(value) {
// Check for null first, since typeof null === "object".
if (value === null) {
return true;
}
const type = typeof value;
return type !== "object" && type !== "function";
}
/**
* Return true if the object is a string or object with a non-trival `toString`
* method.
*
* @param {any} obj
* @returns {obj is import("../index.ts").StringLike}
*/
export function isStringLike(obj) {
if (typeof obj === "string") {
return true;
} else if (obj?.toString === undefined) {
return false;
} else if (obj.toString === getRealmObjectPrototype(obj)?.toString) {
// The stupid Object.prototype.toString implementation always returns
// "[object Object]", so if that's the only toString method the object has,
// we return false.
return false;
} else {
return true;
}
}
export function isUnpackable(obj) {
return (
isPacked(obj) && typeof (/** @type {any} */ (obj).unpack) === "function"
);
}
/**
* Given a path like "/foo/bar/baz", return an array of keys like ["foo/",
* "bar/", "baz"].
*
* Leading slashes are ignored. Consecutive slashes will be ignored. Trailing
* slashes are preserved.
*
* @param {string} pathname
*/
export function keysFromPath(pathname) {
// Split the path at each slash
let keys = pathname.split("/");
if (keys[0] === "") {
// The path begins with a slash; drop that part.
keys.shift();
}
if (keys.at(-1) === "") {
// The path ends with a slash; drop that part.
keys.pop();
}
// Drop any empty keys
keys = keys.filter((key) => key !== "");
// Add the trailing slash back to all keys but the last
for (let i = 0; i < keys.length - 1; i++) {
keys[i] += "/";
}
// Add trailing slash to last key if path ended with a slash
if (keys.length > 0 && trailingSlash.has(pathname)) {
keys[keys.length - 1] += "/";
}
return keys;
}
/**
* Compare two strings using [natural sort
* order](https://en.wikipedia.org/wiki/Natural_sort_order).
*/
export const naturalOrder = new Intl.Collator(undefined, {
numeric: true,
}).compare;
/**
* Return a slash-separated path for the given keys.
*
* This takes care to avoid adding consecutive slashes if they keys themselves
* already have trailing slashes.
*
* @param {string[]} keys
*/
export function pathFromKeys(keys) {
const normalized = keys.map((key) => trailingSlash.remove(key));
return normalized.join("/");
}
/**
* Apply a series of functions to a value, passing the result of each function
* to the next one.
*
* @param {any} start
* @param {...Function} fns
*/
export async function pipeline(start, ...fns) {
return fns.reduce(async (acc, fn) => fn(await acc), start);
}
/**
* If the child object doesn't have a parent yet, set it to the indicated
* parent. If the child is an AsyncTree, set the `parent` property. Otherwise,
* set the `symbols.parent` property.
*
* @param {*} child
* @param {*} parent
*/
export function setParent(child, parent) {
if (Tree.isAsyncTree(child)) {
// Value is a subtree; set its parent to this tree.
if (!child.parent) {
child.parent = parent;
}
} else if (Object.isExtensible(child) && !child[symbols.parent]) {
// Add parent reference as a symbol to avoid polluting the object. This
// reference will be used if the object is later used as a tree. We set
// `enumerable` to false even thought this makes no practical difference
// (symbols are never enumerated) because it can provide a hint in the
// debugger that the property is for internal use.
Object.defineProperty(child, symbols.parent, {
configurable: true,
enumerable: false,
value: parent,
writable: true,
});
}
}
/**
* Convert the given input to the plainest possible JavaScript value. This
* helper is intended for functions that want to accept an argument from the ori
* CLI, which could a string, a stream of data, or some other kind of JavaScript
* object.
*
* If the input is a function, it will be invoked and its result will be
* processed.
*
* If the input is a promise, it will be resolved and its result will be
* processed.
*
* If the input is treelike, it will be converted to a plain JavaScript object,
* recursively traversing the tree and converting all values to plain types.
*
* If the input is stringlike, its text will be returned.
*
* If the input is a ArrayBuffer or typed array, it will be interpreted as UTF-8
* text if it does not contain unprintable characters. If it does, it will be
* returned as a base64-encoded string.
*
* If the input has a custom class instance, its public properties will be
* returned as a plain object.
*
* @param {any} input
* @returns {Promise<any>}
*/
export async function toPlainValue(input) {
if (input instanceof Function) {
// Invoke function
input = input();
}
if (input instanceof Promise) {
// Resolve promise
input = await input;
}
if (isPrimitive(input) || input instanceof Date) {
return input;
} else if (Tree.isTreelike(input)) {
const mapped = await Tree.map(input, (value) => toPlainValue(value));
return Tree.plain(mapped);
} else if (isStringLike(input)) {
return toString(input);
} else if (input instanceof ArrayBuffer || input instanceof TypedArray) {
// Try to interpret the buffer as UTF-8 text, otherwise use base64.
const text = toString(input);
if (text !== null) {
return text;
} else {
return toBase64(input);
}
} else {
// Some other kind of class instance; return its public properties.
const plain = {};
for (const [key, value] of Object.entries(input)) {
plain[key] = await toPlainValue(value);
}
return plain;
}
}
function toBase64(object) {
if (typeof Buffer !== "undefined") {
// Node.js environment
return Buffer.from(object).toString("base64");
} else {
// Browser environment
let binary = "";
const bytes = new Uint8Array(object);
const len = bytes.byteLength;
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
}
/**
* Return a string form of the object, handling cases not generally handled by
* the standard JavaScript `toString()` method:
*
* 1. If the object is an ArrayBuffer or TypedArray, decode the array as UTF-8.
* 2. If the object is otherwise a plain JavaScript object with the useless
* default toString() method, return null instead of "[object Object]". In
* practice, it's generally more useful to have this method fail than to
* return a useless string.
* 3. If the object is a defined primitive value, return the result of
* String(object).
*
* Otherwise return null.
*
* @param {any} object
* @returns {string|null}
*/
export function toString(object) {
if (object instanceof ArrayBuffer || object instanceof TypedArray) {
// Treat the buffer as UTF-8 text.
const decoded = textDecoder.decode(object);
// If the result appears to contain non-printable characters, it's probably not a string.
// https://stackoverflow.com/a/1677660/76472
const hasNonPrintableCharacters = /[\x00-\x08\x0E-\x1F]/.test(decoded);
return hasNonPrintableCharacters ? null : decoded;
} else if (isStringLike(object) || (object !== null && isPrimitive(object))) {
return String(object);
} else {
return null;
}
}

386
node_modules/@weborigami/async-tree/test/Tree.test.js generated vendored Normal file
View File

@@ -0,0 +1,386 @@
import assert from "node:assert";
import { describe, test } from "node:test";
import MapTree from "../src/drivers/MapTree.js";
import { DeepObjectTree, ObjectTree, Tree } from "../src/internal.js";
import * as symbols from "../src/symbols.js";
describe("Tree", () => {
test("assign applies one tree to another", async () => {
const target = new DeepObjectTree({
a: 1,
b: 2,
more: {
d: 3,
},
});
const source = new DeepObjectTree({
a: 4, // Overwrite existing value
b: undefined, // Delete
c: 5, // Add
more: {
// Should leave existing `more` keys alone.
e: 6, // Add
},
// Add new subtree
extra: {
f: 7,
},
});
// Apply changes.
const result = await Tree.assign(target, source);
assert.equal(result, target);
const plain = await Tree.plain(target);
assert.deepEqual(plain, {
a: 4,
c: 5,
more: {
d: 3,
e: 6,
},
extra: {
f: 7,
},
});
});
test("assign() can apply updates to an array", async () => {
const target = new ObjectTree(["a", "b", "c"]);
await Tree.assign(target, ["d", "e"]);
assert.deepEqual(await Tree.plain(target), ["d", "e", "c"]);
});
test("clear() removes all values", async () => {
const fixture = createFixture();
await Tree.clear(fixture);
assert.deepEqual(Array.from(await Tree.entries(fixture)), []);
});
test("entries() returns the [key, value] pairs", async () => {
const fixture = createFixture();
assert.deepEqual(Array.from(await Tree.entries(fixture)), [
["Alice.md", "Hello, **Alice**."],
["Bob.md", "Hello, **Bob**."],
["Carol.md", "Hello, **Carol**."],
]);
});
test("forEach() invokes a callback for each entry", async () => {
const fixture = createFixture();
const results = {};
await Tree.forEach(fixture, async (value, key) => {
results[key] = value;
});
assert.deepEqual(results, {
"Alice.md": "Hello, **Alice**.",
"Bob.md": "Hello, **Bob**.",
"Carol.md": "Hello, **Carol**.",
});
});
test("from() returns an async tree as is", async () => {
const tree1 = new ObjectTree({
a: "Hello, a.",
});
const tree2 = Tree.from(tree1);
assert.equal(tree2, tree1);
});
test("from() uses an object's unpack() method if defined", async () => {
const obj = new String();
/** @type {any} */ (obj).unpack = () => ({
a: "Hello, a.",
});
const tree = Tree.from(obj);
assert.deepEqual(await Tree.plain(tree), {
a: "Hello, a.",
});
});
test("from returns a deep object tree if deep option is true", async () => {
const obj = {
sub: {
a: 1,
},
};
const tree = Tree.from(obj, { deep: true });
assert(tree instanceof DeepObjectTree);
});
test("from returns a deep object tree if object has [deep] symbol set", async () => {
const obj = {
sub: {
a: 1,
},
};
Object.defineProperty(obj, symbols.deep, { value: true });
const tree = Tree.from(obj);
assert(tree instanceof DeepObjectTree);
});
test("from() creates a deferred tree if unpack() returns a promise", async () => {
const obj = new String();
/** @type {any} */ (obj).unpack = async () => ({
a: "Hello, a.",
});
const tree = Tree.from(obj);
assert.deepEqual(await Tree.plain(tree), {
a: "Hello, a.",
});
});
test("from() autoboxes primitive values", async () => {
const tree = Tree.from("Hello, world.");
const slice = await tree.get("slice");
const result = await slice(0, 5);
assert.equal(result, "Hello");
});
test("has returns true if the key exists", async () => {
const fixture = createFixture();
assert.equal(await Tree.has(fixture, "Alice.md"), true);
assert.equal(await Tree.has(fixture, "David.md"), false);
});
test("isAsyncTree returns true if the object is a tree", () => {
const missingGetAndKeys = {};
assert(!Tree.isAsyncTree(missingGetAndKeys));
const missingIterator = {
async get() {},
};
assert(!Tree.isAsyncTree(missingIterator));
const missingGet = {
async keys() {},
};
assert(!Tree.isAsyncTree(missingGet));
const hasGetAndKeys = {
async get() {},
async keys() {},
};
assert(Tree.isAsyncTree(hasGetAndKeys));
});
test("isAsyncMutableTree returns true if the object is a mutable tree", () => {
assert.equal(
Tree.isAsyncMutableTree({
get() {},
keys() {},
}),
false
);
assert.equal(Tree.isAsyncMutableTree(createFixture()), true);
});
test("isTreelike() returns true if the argument can be cast to an async tree", () => {
assert(!Tree.isTreelike(null));
assert(Tree.isTreelike({}));
assert(Tree.isTreelike([]));
assert(Tree.isTreelike(new Map()));
assert(Tree.isTreelike(new Set()));
});
test("map() maps values", async () => {
const tree = new DeepObjectTree({
a: "Alice",
more: {
b: "Bob",
},
});
const mapped = Tree.map(tree, {
deep: true,
value: (value) => value.toUpperCase(),
});
assert.deepEqual(await Tree.plain(mapped), {
a: "ALICE",
more: {
b: "BOB",
},
});
});
test("mapReduce() can map values and reduce them", async () => {
const tree = new DeepObjectTree({
a: 1,
b: 2,
more: {
c: 3,
},
d: 4,
});
const reduced = await Tree.mapReduce(
tree,
(value) => value,
async (values) => String.prototype.concat(...values)
);
assert.deepEqual(reduced, "1234");
});
test("paths returns an array of paths to the values in the tree", async () => {
const tree = new DeepObjectTree({
a: 1,
b: 2,
c: {
d: 3,
e: 4,
},
});
assert.deepEqual(await Tree.paths(tree), ["a", "b", "c/d", "c/e"]);
});
test("plain() produces a plain object version of a tree", async () => {
const tree = new ObjectTree({
a: 1,
// Slashes should be normalized
"sub1/": {
b: 2,
},
sub2: {
c: 3,
},
});
const plain = await Tree.plain(tree);
assert.deepEqual(plain, {
a: 1,
sub1: {
b: 2,
},
sub2: {
c: 3,
},
});
});
test("plain() produces an array for an array-like tree", async () => {
const original = ["a", "b", "c"];
const tree = new ObjectTree(original);
const plain = await Tree.plain(tree);
assert.deepEqual(plain, original);
});
test("plain() leaves an array-like tree as an object if keys aren't consecutive", async () => {
const original = {
0: "a",
1: "b",
// missing
3: "c",
};
const tree = new ObjectTree(original);
const plain = await Tree.plain(tree);
assert.deepEqual(plain, original);
});
test("plain() returns empty array or object for ObjectTree as necessary", async () => {
const tree = new ObjectTree({});
assert.deepEqual(await Tree.plain(tree), {});
const arrayTree = new ObjectTree([]);
assert.deepEqual(await Tree.plain(arrayTree), []);
});
test("plain() awaits async properties", async () => {
const object = {
get name() {
return Promise.resolve("Alice");
},
};
assert.deepEqual(await Tree.plain(object), { name: "Alice" });
});
test("plain() coerces TypedArray values to strings", async () => {
const tree = new ObjectTree({
a: new TextEncoder().encode("Hello, world."),
});
const plain = await Tree.plain(tree);
assert.equal(plain.a, "Hello, world.");
});
test("remove method removes a value", async () => {
const fixture = createFixture();
await Tree.remove(fixture, "Alice.md");
assert.deepEqual(Array.from(await Tree.entries(fixture)), [
["Bob.md", "Hello, **Bob**."],
["Carol.md", "Hello, **Carol**."],
]);
});
test("toFunction returns a function that invokes a tree's get() method", async () => {
const tree = new ObjectTree({
a: 1,
b: 2,
});
const fn = Tree.toFunction(tree);
assert.equal(await fn("a"), 1);
assert.equal(await fn("b"), 2);
});
test("traverse() a path of keys", async () => {
const tree = new ObjectTree({
a1: 1,
a2: {
b1: 2,
b2: {
c1: 3,
c2: 4,
},
},
});
assert.equal(await Tree.traverse(tree), tree);
assert.equal(await Tree.traverse(tree, "a1"), 1);
assert.equal(await Tree.traverse(tree, "a2", "b2", "c2"), 4);
assert.equal(
await Tree.traverse(tree, "a2", "doesntexist", "c2"),
undefined
);
});
test("traverse() a function with fixed number of arguments", async () => {
const tree = (a, b) => ({
c: "Result",
});
assert.equal(await Tree.traverse(tree, "a", "b", "c"), "Result");
});
test("traverse() from one tree into another", async () => {
const tree = new ObjectTree({
a: {
b: new MapTree([
["c", "Hello"],
["d", "Goodbye"],
]),
},
});
assert.equal(await Tree.traverse(tree, "a", "b", "c"), "Hello");
});
test("traversePath() traverses a slash-separated path", async () => {
const tree = new ObjectTree({
a: {
b: {
c: "Hello",
},
},
});
assert.equal(await Tree.traversePath(tree, "a/b/c"), "Hello");
});
test("values() returns the store's values", async () => {
const fixture = createFixture();
assert.deepEqual(Array.from(await Tree.values(fixture)), [
"Hello, **Alice**.",
"Hello, **Bob**.",
"Hello, **Carol**.",
]);
});
});
function createFixture() {
return new ObjectTree({
"Alice.md": "Hello, **Alice**.",
"Bob.md": "Hello, **Bob**.",
"Carol.md": "Hello, **Carol**.",
});
}

View File

@@ -0,0 +1,54 @@
/**
* A simple test runner for the browser to run the subset of the Node.s test
* runner used by the project.
*/
export default function assert(condition) {
if (!condition) {
throw new Error("Assertion failed");
}
}
assert.equal = (actual, expected) => {
if (Number.isNaN(actual) && Number.isNaN(expected)) {
return;
} else if (actual == expected) {
return;
} else {
throw new Error(`Expected ${expected} but got ${actual}`);
}
};
// This is a simplified deepEqual test that examines the conditions we care
// about. For reference, the actual Node assert.deepEqual is much more complex:
// see https://github.com/nodejs/node/blob/main/lib/internal/util/comparisons.js
assert.deepEqual = (actual, expected) => {
if (actual === expected) {
return;
} else if (
typeof actual === "object" &&
actual != null &&
typeof expected === "object" &&
expected != null &&
Object.keys(actual).length === Object.keys(expected).length
) {
for (const prop in actual) {
if (!expected.hasOwnProperty(prop)) {
break;
}
assert.deepEqual(actual[prop], expected[prop]);
}
return;
}
throw new Error(`Expected ${expected} but got ${actual}`);
};
assert.rejects = async (promise) => {
try {
await promise;
throw new Error("Expected promise to reject but it resolved");
} catch (error) {
return;
}
};

View File

@@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<script type="importmap">
{
"imports": {
"node:assert": "./assert.js",
"node:test": "./testRunner.js"
}
}
</script>
<!-- Omit FileTree.test.js, which is Node.js only -->
<!-- Omit SiteTree.test.js, which requires mocks -->
<script type="module" src="../BrowserFileTree.test.js"></script>
<script type="module" src="../DeferredTree.test.js"></script>
<script type="module" src="../FunctionTree.test.js"></script>
<script type="module" src="../MapTree.test.js"></script>
<script type="module" src="../ObjectTree.test.js"></script>
<script type="module" src="../SetTree.test.js"></script>
<script type="module" src="../Tree.test.js"></script>
<script type="module" src="../operations/cache.test.js"></script>
<script type="module" src="../operations/merge.test.js"></script>
<script type="module" src="../operations/deepMerge.test.js"></script>
<script type="module" src="../transforms/cachedKeyMaps.test.js"></script>
<script
type="module"
src="../transforms/keyFunctionsForExtensions.test.js"
></script>
<script type="module" src="../transforms/mapFn.test.js"></script>
<script type="module" src="../utilities.test.js"></script>
</head>
<body></body>
</html>

View File

@@ -0,0 +1,51 @@
/**
* A simple test runner for the browser to run the subset of the Node.s test
* runner used by the project.
*/
let promises = {};
let currentSuite;
const markers = {
success: "✅",
skipped: "ー",
fail: "❌",
};
export async function describe(name, fn) {
promises[name] = [];
currentSuite = name;
await fn();
const results = await Promise.all(promises[name]);
const someFailed = results.some((result) => result.result === "fail");
const header = `${someFailed ? markers.fail : markers.success} ${name}`;
console[someFailed ? "group" : "groupCollapsed"](header);
for (const result of results) {
const marker = markers[result.result];
const name = result.name;
const message = result.result === "fail" ? `: ${result.message}` : "";
const skipped = result.result === "skipped" ? " [skipped]" : "";
console.log(`${marker} ${name}${message}${skipped}`);
}
console.groupEnd();
}
// Node test() calls can call an async function, but the test() function isn't
// declared async. We implicitly wrap the test call with a Promise and add it to
// the list of promises for the current suite.
export async function test(name, fn) {
promises[currentSuite].push(runTest(name, fn));
}
test.skip = (name, fn) => {
promises[currentSuite].push(Promise.resolve({ result: "skipped", name }));
};
async function runTest(name, fn) {
try {
await fn();
return { result: "success", name };
} catch (/** @type {any} */ error) {
return { result: "fail", name, message: error.message };
}
}

View File

@@ -0,0 +1,153 @@
import assert from "node:assert";
import { describe, test } from "node:test";
import BrowserFileTree from "../../src/drivers/BrowserFileTree.js";
import { Tree } from "../../src/internal.js";
// Skip these tests if we're not in a browser.
const isBrowser = typeof window !== "undefined";
if (isBrowser) {
describe("BrowserFileTree", async () => {
test("can get the keys of the tree", async () => {
const fixture = await createFixture();
assert.deepEqual(Array.from(await fixture.keys()), [
"Alice.md",
"Bob.md",
"Carol.md",
"subfolder/",
]);
});
test("can get the value for a key", async () => {
const fixture = await createFixture();
const buffer = await fixture.get("Alice.md");
assert.equal(text(buffer), "Hello, **Alice**.");
});
test("getting an unsupported key returns undefined", async () => {
const fixture = await createFixture();
assert.equal(await fixture.get("xyz"), undefined);
});
test("getting empty key returns undefined", async () => {
const fixture = await createFixture();
assert.equal(await fixture.get(""), undefined);
});
test("getting a null/undefined key throws an exception", async () => {
const fixture = await createFixture();
await assert.rejects(async () => {
await fixture.get(null);
});
await assert.rejects(async () => {
await fixture.get(undefined);
});
});
test("sets parent on subtrees", async () => {
const fixture = await createFixture();
const subfolder = await fixture.get("subfolder");
assert.equal(subfolder.parent, fixture);
});
test("can retrieve values with optional trailing slash", async () => {
const fixture = await createFixture();
assert(await fixture.get("Alice.md"));
assert(await fixture.get("Alice.md/"));
assert(await fixture.get("subfolder"));
assert(await fixture.get("subfolder/"));
});
test("can set a value", async () => {
const fixture = await createFixture();
// Update existing key.
await fixture.set("Alice.md", "Goodbye, **Alice**.");
// New key.
await fixture.set("David.md", "Hello, **David**.");
// Delete key.
await fixture.set("Bob.md", undefined);
// Delete non-existent key.
await fixture.set("xyz", undefined);
assert.deepEqual(await strings(fixture), {
"Alice.md": "Goodbye, **Alice**.",
"Carol.md": "Hello, **Carol**.",
"David.md": "Hello, **David**.",
subfolder: {},
});
});
test("can create a subfolder via set", async () => {
const fixture = await createFixture();
const tree = {
async get(key) {
const name = key.replace(/\.md$/, "");
return `Hello, **${name}**.`;
},
async keys() {
return ["Ellen.md"];
},
};
await fixture.set("more", tree);
assert.deepEqual(await strings(fixture), {
"Alice.md": "Hello, **Alice**.",
"Bob.md": "Hello, **Bob**.",
"Carol.md": "Hello, **Carol**.",
more: {
"Ellen.md": "Hello, **Ellen**.",
},
subfolder: {},
});
});
});
}
async function createFile(directory, name, contents) {
const file = await directory.getFileHandle(name, { create: true });
const writable = await file.createWritable();
await writable.write(contents);
await writable.close();
}
let count = 0;
async function createFixture() {
const root = await navigator.storage.getDirectory();
const directory = await root.getDirectoryHandle("async-tree", {
create: true,
});
// Create a new subdirectory for each test.
const subdirectoryName = `test${count++}`;
// Delete any pre-existing subdirectory with that name.
try {
await directory.removeEntry(subdirectoryName, { recursive: true });
} catch (e) {
// Ignore errors.
}
const subdirectory = await directory.getDirectoryHandle(subdirectoryName, {
create: true,
});
await createFile(subdirectory, "Alice.md", "Hello, **Alice**.");
await createFile(subdirectory, "Bob.md", "Hello, **Bob**.");
await createFile(subdirectory, "Carol.md", "Hello, **Carol**.");
await subdirectory.getDirectoryHandle("subfolder", {
create: true,
});
return new BrowserFileTree(subdirectory);
}
async function strings(tree) {
return Tree.plain(Tree.map(tree, (value) => text(value)));
}
function text(arrayBuffer) {
return new TextDecoder().decode(arrayBuffer);
}

View File

@@ -0,0 +1,17 @@
import assert from "node:assert";
import { describe, test } from "node:test";
import DeepMapTree from "../../src/drivers/DeepMapTree.js";
import { Tree } from "../../src/internal.js";
describe("DeepMapTree", () => {
test("returns a DeepMapTree for value that's a Map", async () => {
const tree = new DeepMapTree([
["a", 1],
["map", new Map([["b", 2]])],
]);
const map = await tree.get("map");
assert.equal(map instanceof DeepMapTree, true);
assert.deepEqual(await Tree.plain(map), { b: 2 });
assert.equal(map.parent, tree);
});
});

View File

@@ -0,0 +1,35 @@
import assert from "node:assert";
import { describe, test } from "node:test";
import { DeepObjectTree, Tree } from "../../src/internal.js";
describe("DeepObjectTree", () => {
test("returns an ObjectTree for value that's a plain sub-object or sub-array", async () => {
const tree = createFixture();
const object = await tree.get("object");
assert.equal(object instanceof DeepObjectTree, true);
assert.deepEqual(await Tree.plain(object), { b: 2 });
assert.equal(object.parent, tree);
const array = await tree.get("array");
assert.equal(array instanceof DeepObjectTree, true);
assert.deepEqual(await Tree.plain(array), [3]);
assert.equal(array.parent, tree);
});
test("adds trailing slashes to keys for subtrees including plain objects or arrays", async () => {
const tree = createFixture();
const keys = Array.from(await tree.keys());
assert.deepEqual(keys, ["a", "object/", "array/"]);
});
});
function createFixture() {
return new DeepObjectTree({
a: 1,
object: {
b: 2,
},
array: [3],
});
}

View File

@@ -0,0 +1,22 @@
import assert from "node:assert";
import { describe, test } from "node:test";
import DeferredTree from "../../src/drivers/DeferredTree.js";
import { ObjectTree, Tree } from "../../src/internal.js";
describe("DeferredTree", () => {
test("lazy-loads a treelike object", async () => {
const tree = new DeferredTree(async () => ({ a: 1, b: 2, c: 3 }));
assert.deepEqual(await Tree.plain(tree), { a: 1, b: 2, c: 3 });
});
test("sets parent on subtrees", async () => {
const object = {
a: 1,
};
const parent = new ObjectTree({});
const fixture = new DeferredTree(() => object);
fixture.parent = parent;
const tree = await fixture.tree();
assert.equal(tree.parent, parent);
});
});

View File

@@ -0,0 +1,116 @@
import assert from "node:assert";
import { beforeEach, describe, mock, test } from "node:test";
import ExplorableSiteTree from "../../src/drivers/ExplorableSiteTree.js";
import { Tree } from "../../src/internal.js";
const textDecoder = new TextDecoder();
const textEncoder = new TextEncoder();
const mockHost = "https://mock";
const mockResponses = {
"/.keys.json": {
data: JSON.stringify(["about/", "index.html"]),
},
"/about": {
redirected: true,
status: 301,
url: "https://mock/about/",
},
"/about/.keys.json": {
data: JSON.stringify(["Alice.html", "Bob.html", "Carol.html"]),
},
"/about/Alice.html": {
data: "Hello, Alice!",
},
"/about/Bob.html": {
data: "Hello, Bob!",
},
"/about/Carol.html": {
data: "Hello, Carol!",
},
"/index.html": {
data: "Home page",
},
};
describe("ExplorableSiteTree", () => {
beforeEach(() => {
mock.method(global, "fetch", mockFetch);
});
test("can get the keys of a tree", async () => {
const fixture = new ExplorableSiteTree(mockHost);
const keys = await fixture.keys();
assert.deepEqual(Array.from(keys), ["about/", "index.html"]);
});
test("can get a plain value for a key", async () => {
const fixture = new ExplorableSiteTree(mockHost);
const arrayBuffer = await fixture.get("index.html");
const text = textDecoder.decode(arrayBuffer);
assert.equal(text, "Home page");
});
test("getting an unsupported key returns undefined", async () => {
const fixture = new ExplorableSiteTree(mockHost);
assert.equal(await fixture.get("xyz"), undefined);
});
test("getting a null/undefined key throws an exception", async () => {
const fixture = new ExplorableSiteTree(mockHost);
await assert.rejects(async () => {
await fixture.get(null);
});
await assert.rejects(async () => {
await fixture.get(undefined);
});
});
test("can return a new tree for a key that redirects", async () => {
const fixture = new ExplorableSiteTree(mockHost);
const about = await fixture.get("about");
assert(about instanceof ExplorableSiteTree);
assert.equal(about.href, "https://mock/about/");
});
test("can convert a site to a plain object", async () => {
const fixture = new ExplorableSiteTree(mockHost);
// Convert buffers to strings.
const strings = Tree.map(fixture, {
deep: true,
value: (value) => textDecoder.decode(value),
});
assert.deepEqual(await Tree.plain(strings), {
about: {
"Alice.html": "Hello, Alice!",
"Bob.html": "Hello, Bob!",
"Carol.html": "Hello, Carol!",
},
"index.html": "Home page",
});
});
});
async function mockFetch(href) {
if (!href.startsWith(mockHost)) {
return { status: 404 };
}
const path = href.slice(mockHost.length);
const mockedResponse = mockResponses[path];
if (mockedResponse) {
return Object.assign(
{
arrayBuffer: () => textEncoder.encode(mockedResponse.data).buffer,
ok: true,
status: 200,
text: () => mockedResponse.data,
},
mockedResponse
);
}
return {
ok: false,
status: 404,
};
}

View File

@@ -0,0 +1,192 @@
import assert from "node:assert";
import * as fs from "node:fs/promises";
import path from "node:path";
import { describe, test } from "node:test";
import { fileURLToPath } from "node:url";
import FileTree from "../../src/drivers/FileTree.js";
import { ObjectTree, Tree } from "../../src/internal.js";
const dirname = path.dirname(fileURLToPath(import.meta.url));
const tempDirectory = path.join(dirname, "fixtures/temp");
const textDecoder = new TextDecoder();
describe("FileTree", async () => {
test("can get the keys of the tree", async () => {
const fixture = createFixture("fixtures/markdown");
assert.deepEqual(Array.from(await fixture.keys()), [
"Alice.md",
"Bob.md",
"Carol.md",
"subfolder/",
]);
});
test("can get the value for a key", async () => {
const fixture = createFixture("fixtures/markdown");
const buffer = await fixture.get("Alice.md");
const text = textDecoder.decode(buffer);
assert.equal(text, "Hello, **Alice**.");
});
test("getting an unsupported key returns undefined", async () => {
const fixture = createFixture("fixtures/markdown");
assert.equal(await fixture.get("xyz"), undefined);
});
test("getting empty key returns undefined", async () => {
const fixture = createFixture("fixtures/markdown");
assert.equal(await fixture.get(""), undefined);
});
test("getting a null/undefined key throws an exception", async () => {
const fixture = createFixture("fixtures/markdown");
await assert.rejects(async () => {
await fixture.get(null);
});
await assert.rejects(async () => {
await fixture.get(undefined);
});
});
test("can retrieve values with optional trailing slash", async () => {
const fixture = createFixture("fixtures/markdown");
assert(await fixture.get("Alice.md"));
assert(await fixture.get("Alice.md/"));
assert(await fixture.get("subfolder"));
assert(await fixture.get("subfolder/"));
});
test("sets parent on subtrees", async () => {
const fixture = createFixture("fixtures");
const markdown = await fixture.get("markdown");
assert.equal(markdown.parent, fixture);
});
test("can write out a file via set()", async () => {
await createTempDirectory();
// Write out a file.
const fileName = "file1";
const fileText = "This is the first file.";
const tempFiles = new FileTree(tempDirectory);
await tempFiles.set(fileName, fileText);
// Read it back in.
const filePath = path.join(tempDirectory, fileName);
const actualText = String(await fs.readFile(filePath));
assert.equal(fileText, actualText);
await removeTempDirectory();
});
test("create subfolder via set() with empty object value", async () => {
await createTempDirectory();
// Write out new, empty folder called "empty".
const tempFiles = new FileTree(tempDirectory);
await tempFiles.set("empty", {});
// Verify folder exists and has no contents.
const folderPath = path.join(tempDirectory, "empty");
const stats = await fs.stat(folderPath);
assert(stats.isDirectory());
const files = await fs.readdir(folderPath);
assert.deepEqual(files, []);
await removeTempDirectory();
});
test("create subfolder via set() with empty tree value", async () => {
await createTempDirectory();
// Write out new, empty folder called "empty".
const tempFiles = new FileTree(tempDirectory);
await tempFiles.set("empty", new ObjectTree({}));
// Verify folder exists and has no contents.
const folderPath = path.join(tempDirectory, "empty");
const stats = await fs.stat(folderPath);
assert(stats.isDirectory());
const files = await fs.readdir(folderPath);
assert.deepEqual(files, []);
await removeTempDirectory();
});
test("can write out subfolder via set()", async () => {
await createTempDirectory();
// Create a tiny set of "files".
const obj = {
file1: "This is the first file.",
subfolder: {
file2: "This is the second file.",
},
};
// Write out files as a new folder called "folder".
const tempFiles = new FileTree(tempDirectory);
await tempFiles.set("folder", obj);
// Read them back in.
const actualFiles = await tempFiles.get("folder");
const strings = Tree.map(actualFiles, {
deep: true,
value: (buffer) => textDecoder.decode(buffer),
});
const plain = await Tree.plain(strings);
assert.deepEqual(plain, obj);
await removeTempDirectory();
});
test("can delete a file via set()", async () => {
await createTempDirectory();
const tempFile = path.join(tempDirectory, "file");
await fs.writeFile(tempFile, "");
const tempFiles = new FileTree(tempDirectory);
await tempFiles.set("file", undefined);
let stats;
try {
stats = await fs.stat(tempFile);
} catch (/** @type {any} */ error) {
if (error.code !== "ENOENT") {
throw error;
}
}
assert(stats === undefined);
await removeTempDirectory();
});
test("can delete a folder via set()", async () => {
await createTempDirectory();
const folder = path.join(tempDirectory, "folder");
await fs.mkdir(folder);
const tempFiles = new FileTree(tempDirectory);
await tempFiles.set("folder", undefined);
let stats;
try {
stats = await fs.stat(folder);
} catch (/** @type {any} */ error) {
if (error.code !== "ENOENT") {
throw error;
}
}
assert(stats === undefined);
await removeTempDirectory();
});
});
function createFixture(fixturePath) {
return new FileTree(path.join(dirname, fixturePath));
}
async function createTempDirectory() {
await fs.mkdir(tempDirectory, { recursive: true });
}
async function removeTempDirectory() {
await fs.rm(tempDirectory, { recursive: true });
}

View File

@@ -0,0 +1,46 @@
import assert from "node:assert";
import { describe, test } from "node:test";
import FunctionTree from "../../src/drivers/FunctionTree.js";
describe("FunctionTree", async () => {
test("can get the keys of the tree", async () => {
const fixture = createFixture();
assert.deepEqual(Array.from(await fixture.keys()), [
"Alice.md",
"Bob.md",
"Carol.md",
]);
});
test("can get the value for a key", async () => {
const fixture = createFixture();
const alice = await fixture.get("Alice.md");
assert.equal(alice, "Hello, **Alice**.");
});
test("getting a value from function with multiple arguments curries the function", async () => {
const fixture = new FunctionTree((a, b, c) => a + b + c);
const fnA = await fixture.get(1);
const fnAB = await fnA.get(2);
const result = await fnAB.get(3);
assert.equal(result, 6);
});
test("getting an unsupported key returns undefined", async () => {
const fixture = createFixture();
assert.equal(await fixture.get("xyz"), undefined);
});
});
function createFixture() {
return new FunctionTree(
(key) => {
if (key?.endsWith?.(".md")) {
const name = key.slice(0, -3);
return `Hello, **${name}**.`;
}
return undefined;
},
["Alice.md", "Bob.md", "Carol.md"]
);
}

View File

@@ -0,0 +1,59 @@
import assert from "node:assert";
import { describe, test } from "node:test";
import MapTree from "../../src/drivers/MapTree.js";
import * as symbols from "../../src/symbols.js";
describe("MapTree", () => {
test("can get the keys of the tree", async () => {
const fixture = createFixture();
assert.deepEqual(Array.from(await fixture.keys()), ["a", "b", "c"]);
});
test("can get the value for a key", async () => {
const fixture = createFixture();
const a = await fixture.get("a");
assert.equal(a, 1);
});
test("sets parent on subtrees", async () => {
const map = new Map([["more", new Map([["a", 1]])]]);
const fixture = new MapTree(map);
const more = await fixture.get("more");
assert.equal(more[symbols.parent], fixture);
});
test("adds trailing slashes to keys for subtrees", async () => {
const tree = new MapTree([
["a", 1],
["subtree", new MapTree([["b", 2]])],
]);
const keys = Array.from(await tree.keys());
assert.deepEqual(keys, ["a", "subtree/"]);
});
test("can retrieve values with optional trailing slash", async () => {
const subtree = new MapTree([["b", 2]]);
const tree = new MapTree([
["a", 1],
["subtree", subtree],
]);
assert.equal(await tree.get("a"), 1);
assert.equal(await tree.get("a/"), 1);
assert.equal(await tree.get("subtree"), subtree);
assert.equal(await tree.get("subtree/"), subtree);
});
test("getting an unsupported key returns undefined", async () => {
const fixture = createFixture();
assert.equal(await fixture.get("d"), undefined);
});
});
function createFixture() {
const map = new Map([
["a", 1],
["b", 2],
["c", 3],
]);
return new MapTree(map);
}

View File

@@ -0,0 +1,156 @@
import assert from "node:assert";
import { describe, test } from "node:test";
import { ObjectTree, Tree } from "../../src/internal.js";
import * as symbols from "../../src/symbols.js";
describe("ObjectTree", () => {
test("can get the keys of the tree", async () => {
const fixture = createFixture();
assert.deepEqual(Array.from(await fixture.keys()), [
"Alice.md",
"Bob.md",
"Carol.md",
]);
});
test("can get the value for a key", async () => {
const fixture = createFixture();
const alice = await fixture.get("Alice.md");
assert.equal(alice, "Hello, **Alice**.");
});
test("getting an unsupported key returns undefined", async () => {
const fixture = createFixture();
assert.equal(await fixture.get("xyz"), undefined);
});
test("getting a null/undefined key throws an exception", async () => {
const fixture = createFixture();
await assert.rejects(async () => {
await fixture.get(null);
});
await assert.rejects(async () => {
await fixture.get(undefined);
});
});
test("can set a value", async () => {
const tree = new ObjectTree({
a: 1,
b: 2,
c: 3,
});
// Update existing key
await tree.set("a", 4);
// Delete key
await tree.set("b", undefined);
// Overwrite key with trailing slash
await tree.set("c/", {});
// New key
await tree.set("d", 5);
assert.deepEqual(await Tree.entries(tree), [
["a", 4],
["c/", {}],
["d", 5],
]);
});
test("can wrap a class instance", async () => {
class Foo {
constructor() {
this.a = 1;
}
get prop() {
return this._prop;
}
set prop(prop) {
this._prop = prop;
}
}
class Bar extends Foo {
method() {}
}
const bar = new Bar();
/** @type {any} */ (bar).extra = "Hello";
const fixture = new ObjectTree(bar);
assert.deepEqual(await Tree.entries(fixture), [
["a", 1],
["extra", "Hello"],
["prop", undefined],
]);
assert.equal(await fixture.get("a"), 1);
await fixture.set("prop", "Goodbye");
assert.equal(bar.prop, "Goodbye");
assert.equal(await fixture.get("prop"), "Goodbye");
});
test("sets parent symbol on subobjects", async () => {
const fixture = new ObjectTree({
sub: {},
});
const sub = await fixture.get("sub");
assert.equal(sub[symbols.parent], fixture);
});
test("sets parent on subtrees", async () => {
const fixture = new ObjectTree({
a: 1,
more: new ObjectTree({
b: 2,
}),
});
const more = await fixture.get("more");
assert.equal(more.parent, fixture);
});
test("adds trailing slashes to keys for subtrees", async () => {
const tree = new ObjectTree({
a1: 1,
a2: new ObjectTree({
b1: 2,
}),
a3: 3,
a4: new ObjectTree({
b2: 4,
}),
});
const keys = Array.from(await tree.keys());
assert.deepEqual(keys, ["a1", "a2/", "a3", "a4/"]);
});
test("can retrieve values with optional trailing slash", async () => {
const subtree = {
async get(key) {},
async keys() {},
};
const tree = new ObjectTree({
a: 1,
subtree,
});
assert.equal(await tree.get("a"), 1);
assert.equal(await tree.get("a/"), 1);
assert.equal(await tree.get("subtree"), subtree);
assert.equal(await tree.get("subtree/"), subtree);
});
test("method on an object is bound to the object", async () => {
const n = new Number(123);
const tree = new ObjectTree(n);
const method = await tree.get("toString");
assert.equal(method(), "123");
});
});
function createFixture() {
return new ObjectTree({
"Alice.md": "Hello, **Alice**.",
"Bob.md": "Hello, **Bob**.",
"Carol.md": "Hello, **Carol**.",
});
}

View File

@@ -0,0 +1,44 @@
import assert from "node:assert";
import { describe, test } from "node:test";
import SetTree from "../../src/drivers/SetTree.js";
import { ObjectTree } from "../../src/internal.js";
describe("SetTree", () => {
test("can get the keys of the tree", async () => {
const set = new Set(["a", "b", "c"]);
const fixture = new SetTree(set);
assert.deepEqual(Array.from(await fixture.keys()), [0, 1, 2]);
});
test("can get the value for a key", async () => {
const set = new Set(["a", "b", "c"]);
const fixture = new SetTree(set);
const a = await fixture.get(0);
assert.equal(a, "a");
});
test("getting an unsupported key returns undefined", async () => {
const set = new Set(["a", "b", "c"]);
const fixture = new SetTree(set);
assert.equal(await fixture.get(3), undefined);
});
test("getting a null/undefined key throws an exception", async () => {
const set = new Set(["a", "b", "c"]);
const fixture = new SetTree(set);
await assert.rejects(async () => {
await fixture.get(null);
});
await assert.rejects(async () => {
await fixture.get(undefined);
});
});
test("sets parent on subtrees", async () => {
const set = new Set();
set.add(new ObjectTree({}));
const fixture = new SetTree(set);
const subtree = await fixture.get(0);
assert.equal(subtree.parent, fixture);
});
});

View File

@@ -0,0 +1,92 @@
import assert from "node:assert";
import { beforeEach, describe, mock, test } from "node:test";
import SiteTree from "../../src/drivers/SiteTree.js";
const textDecoder = new TextDecoder();
const textEncoder = new TextEncoder();
const mockHost = "https://mock";
const mockResponses = {
"/about": {
redirected: true,
status: 301,
url: "https://mock/about/",
},
"/about/Alice.html": {
data: "Hello, Alice!",
},
"/about/Bob.html": {
data: "Hello, Bob!",
},
"/about/Carol.html": {
data: "Hello, Carol!",
},
"/index.html": {
data: "Home page",
},
};
describe("SiteTree", () => {
beforeEach(() => {
mock.method(global, "fetch", mockFetch);
});
test("returns an empty array as the keys of a tree", async () => {
const fixture = new SiteTree(mockHost);
const keys = await fixture.keys();
assert.deepEqual(Array.from(keys), []);
});
test("can get a plain value for a key", async () => {
const fixture = new SiteTree(mockHost);
const arrayBuffer = await fixture.get("index.html");
const text = textDecoder.decode(arrayBuffer);
assert.equal(text, "Home page");
});
test("immediately return a new tree for a key with a trailing slash", async () => {
const fixture = new SiteTree(mockHost);
const about = await fixture.get("about/");
assert(about instanceof SiteTree);
assert.equal(about.href, "https://mock/about/");
});
test("getting an unsupported key returns undefined", async () => {
const fixture = new SiteTree(mockHost);
assert.equal(await fixture.get("xyz"), undefined);
});
test("getting a null/undefined key throws an exception", async () => {
const fixture = new SiteTree(mockHost);
await assert.rejects(async () => {
await fixture.get(null);
});
await assert.rejects(async () => {
await fixture.get(undefined);
});
});
});
async function mockFetch(href) {
if (!href.startsWith(mockHost)) {
return { status: 404 };
}
const path = href.slice(mockHost.length);
const mockedResponse = mockResponses[path];
if (mockedResponse) {
return Object.assign(
{
arrayBuffer: () => textEncoder.encode(mockedResponse.data).buffer,
ok: true,
status: 200,
text: () => mockedResponse.data,
},
mockedResponse
);
}
return {
ok: false,
status: 404,
};
}

View File

@@ -0,0 +1,121 @@
import assert from "node:assert";
import { describe, test } from "node:test";
import calendar from "../../src/drivers/calendarTree.js";
import { toPlainValue } from "../../src/utilities.js";
describe("calendarTree", () => {
test("without a start or end, returns a tree for today", async () => {
const tree = calendar({
value: (year, month, day) => `${year}-${month}-${day}`,
});
const plain = await toPlainValue(tree);
const today = new Date();
const year = today.getFullYear();
const month = (today.getMonth() + 1).toString().padStart(2, "0");
const day = today.getDate().toString().padStart(2, "0");
assert.deepEqual(plain, {
[year]: {
[month]: {
[day]: `${year}-${month}-${day}`,
},
},
});
});
test("returns a tree for a month range", async () => {
const tree = calendar({
start: "2025-01",
end: "2025-02",
value: (year, month, day) => `${year}-${month}-${day}`,
});
const plain = await toPlainValue(tree);
assert.deepEqual(plain, {
2025: {
"01": {
"01": "2025-01-01",
"02": "2025-01-02",
"03": "2025-01-03",
"04": "2025-01-04",
"05": "2025-01-05",
"06": "2025-01-06",
"07": "2025-01-07",
"08": "2025-01-08",
"09": "2025-01-09",
10: "2025-01-10",
11: "2025-01-11",
12: "2025-01-12",
13: "2025-01-13",
14: "2025-01-14",
15: "2025-01-15",
16: "2025-01-16",
17: "2025-01-17",
18: "2025-01-18",
19: "2025-01-19",
20: "2025-01-20",
21: "2025-01-21",
22: "2025-01-22",
23: "2025-01-23",
24: "2025-01-24",
25: "2025-01-25",
26: "2025-01-26",
27: "2025-01-27",
28: "2025-01-28",
29: "2025-01-29",
30: "2025-01-30",
31: "2025-01-31",
},
"02": {
"01": "2025-02-01",
"02": "2025-02-02",
"03": "2025-02-03",
"04": "2025-02-04",
"05": "2025-02-05",
"06": "2025-02-06",
"07": "2025-02-07",
"08": "2025-02-08",
"09": "2025-02-09",
10: "2025-02-10",
11: "2025-02-11",
12: "2025-02-12",
13: "2025-02-13",
14: "2025-02-14",
15: "2025-02-15",
16: "2025-02-16",
17: "2025-02-17",
18: "2025-02-18",
19: "2025-02-19",
20: "2025-02-20",
21: "2025-02-21",
22: "2025-02-22",
23: "2025-02-23",
24: "2025-02-24",
25: "2025-02-25",
26: "2025-02-26",
27: "2025-02-27",
28: "2025-02-28",
},
},
});
});
test("returns a tree for a day range", async () => {
const tree = calendar({
start: "2025-02-27",
end: "2025-03-02",
value: (year, month, day) => `${year}-${month}-${day}`,
});
const plain = await toPlainValue(tree);
assert.deepEqual(plain, {
2025: {
"02": {
27: "2025-02-27",
28: "2025-02-28",
},
"03": {
"01": "2025-03-01",
"02": "2025-03-02",
},
},
});
});
});

View File

@@ -0,0 +1 @@
Hello, **Alice**.

View File

@@ -0,0 +1 @@
Hello, **Bob**.

View File

@@ -0,0 +1 @@
Hello, **Carol**.

View File

@@ -0,0 +1 @@
This file exists to force the creation of the parent folder.

View File

@@ -0,0 +1,41 @@
import assert from "node:assert";
import { describe, test } from "node:test";
import { extname, match, replace } from "../src/extension.js";
describe("extension", () => {
test("extname", () => {
assert.equal(extname(".\\"), "");
assert.equal(extname("..\\"), ".\\");
assert.equal(extname("file.ext\\"), ".ext\\");
assert.equal(extname("file.ext\\\\"), ".ext\\\\");
assert.equal(extname("file\\"), "");
assert.equal(extname("file\\\\"), "");
assert.equal(extname("file.\\"), ".\\");
assert.equal(extname("file.\\\\"), ".\\\\");
});
test("match", () => {
assert.equal(match("file.md", ".md"), "file");
assert.equal(match("file.md", ".txt"), null);
assert.equal(match("file.md/", ".md"), "file/");
assert.equal(match("file", ""), "file");
assert.equal(match("file", "/"), null);
assert.equal(match("file/", "/"), "file");
});
test("match can handle multi-part extensions", () => {
assert.equal(match("foo.ori.html", ".ori.html"), "foo");
assert.equal(match("foo.ori.html", ".html"), "foo.ori");
assert.equal(match("foo.ori.html", ".txt"), null);
assert.equal(match("foo.ori.html/", ".ori.html"), "foo/");
});
test("replace", () => {
assert.equal(replace("file.md", ".md", ".html"), "file.html");
assert.equal(replace("file.md", ".txt", ".html"), "file.md");
assert.equal(replace("file.md/", ".md", ".html"), "file.html/");
assert.equal(replace("folder/", "", ".html"), "folder.html");
assert.equal(replace("folder", "/", ".html"), "folder");
assert.equal(replace("folder/", "/", ".html"), "folder.html");
});
});

View File

@@ -0,0 +1,15 @@
import { DeepObjectTree } from "@weborigami/async-tree";
import assert from "node:assert";
import { describe, test } from "node:test";
import * as jsonKeys from "../src/jsonKeys.js";
describe("jsonKeys", () => {
test("stringifies JSON Keys", async () => {
const tree = new DeepObjectTree({
about: {},
"index.html": "Home",
});
const json = await jsonKeys.stringify(tree);
assert.strictEqual(json, '["about/","index.html"]');
});
});

View File

@@ -0,0 +1,63 @@
import assert from "node:assert";
import { describe, test } from "node:test";
import { DeepObjectTree, ObjectTree, Tree } from "../../src/internal.js";
import cache from "../../src/operations/cache.js";
describe("cache", () => {
test("caches reads of values from one tree into another", async () => {
const objectCache = new ObjectTree({});
const fixture = cache(
new DeepObjectTree({
a: 1,
b: 2,
c: 3,
more: {
d: 4,
},
}),
objectCache
);
const keys = [...(await fixture.keys())];
assert.deepEqual(keys, ["a", "b", "c", "more/"]);
assert.equal(await objectCache.get("a"), undefined);
assert.equal(await fixture.get("a"), 1);
assert.equal(await objectCache.get("a"), 1); // Now in cache
assert.equal(await objectCache.get("b"), undefined);
assert.equal(await fixture.get("b"), 2);
assert.equal(await objectCache.get("b"), 2);
assert.equal(await objectCache.get("more"), undefined);
const more = await fixture.get("more");
assert.deepEqual([...(await more.keys())], ["d"]);
assert.equal(await more.get("d"), 4);
const moreCache = await objectCache.get("more");
assert.equal(await moreCache.get("d"), 4);
});
test("if a cache filter is supplied, it only caches values whose keys match the filter", async () => {
const objectCache = new ObjectTree({});
const fixture = cache(
Tree.from({
"a.txt": "a",
"b.txt": "b",
}),
objectCache,
Tree.from({
"a.txt": true,
})
);
// Access some values to populate the cache.
assert.equal(await fixture.get("a.txt"), "a");
assert.equal(await fixture.get("b.txt"), "b");
// The a.txt value should be cached because it matches the filter.
assert.equal(await objectCache.get("a.txt"), "a");
// The b.txt value should not be cached because it does not match the filter.
assert.equal(await objectCache.get("b.txt"), undefined);
});
});

View File

@@ -0,0 +1,90 @@
import assert from "node:assert";
import { describe, test } from "node:test";
import { DeepObjectTree, ObjectTree } from "../../src/internal.js";
import cachedKeyFunctions from "../../src/operations/cachedKeyFunctions.js";
describe("cachedKeyFunctions", () => {
test("maps keys with caching", async () => {
const tree = new ObjectTree({
a: "letter a",
b: "letter b",
});
let callCount = 0;
const addUnderscore = async (sourceKey, tree) => {
callCount++;
return `_${sourceKey}`;
};
const { inverseKey, key } = cachedKeyFunctions(addUnderscore);
assert.equal(await inverseKey("_a", tree), "a"); // Cache miss
assert.equal(callCount, 1);
assert.equal(await inverseKey("_a", tree), "a");
assert.equal(callCount, 1);
assert.equal(await inverseKey("_b", tree), "b"); // Cache miss
assert.equal(callCount, 2);
assert.equal(await key("a", tree), "_a");
assert.equal(await key("a", tree), "_a");
assert.equal(await key("b", tree), "_b");
assert.equal(callCount, 2);
// `c` isn't in tree, so we should get undefined.
assert.equal(await inverseKey("_c", tree), undefined);
// But key mapping is still possible.
assert.equal(await key("c", tree), "_c");
// And now we have a cache hit.
assert.equal(await inverseKey("_c", tree), "c");
assert.equal(callCount, 3);
});
test("maps keys with caching and deep option", async () => {
const tree = new DeepObjectTree({
a: "letter a",
b: {
c: "letter c",
},
});
let callCount = 0;
const addUnderscore = async (sourceKey, tree) => {
callCount++;
return `_${sourceKey}`;
};
const { inverseKey, key } = cachedKeyFunctions(addUnderscore, true);
assert.equal(await inverseKey("_a", tree), "a"); // Cache miss
assert.equal(await inverseKey("_a", tree), "a");
assert.equal(callCount, 1);
// Subtree key left alone
assert.equal(await inverseKey("_b", tree), undefined);
assert.equal(await inverseKey("b", tree), "b");
assert.equal(await inverseKey("b/", tree), "b/");
assert.equal(callCount, 1);
assert.equal(await key("a", tree), "_a");
assert.equal(await key("a", tree), "_a");
assert.equal(callCount, 1);
assert.equal(await key("b/", tree), "b/");
assert.equal(await key("b", tree), "b");
assert.equal(callCount, 1);
});
test("preserves trailing slashes", async () => {
const tree = new ObjectTree({
a: "letter a",
});
const addUnderscore = async (sourceKey) => `_${sourceKey}`;
const { inverseKey, key } = cachedKeyFunctions(addUnderscore);
assert.equal(await key("a/", tree), "_a/");
assert.equal(await key("a", tree), "_a");
assert.equal(await inverseKey("_a/", tree), "a/");
assert.equal(await inverseKey("_a", tree), "a");
});
});

View File

@@ -0,0 +1,34 @@
import assert from "node:assert";
import { describe, test } from "node:test";
import FunctionTree from "../../src/drivers/FunctionTree.js";
import { Tree } from "../../src/internal.js";
import concat from "../../src/operations/concat.js";
describe("concat", () => {
test("concatenates deep tree values", async () => {
const tree = Tree.from({
a: "A",
b: "B",
c: "C",
more: {
d: "D",
e: "E",
},
});
const result = await concat.call(null, tree);
assert.equal(result, "ABCDE");
});
test("concatenates deep tree-like values", async () => {
const letters = ["a", "b", "c"];
const specimens = new FunctionTree(
(letter) => ({
lowercase: letter,
uppercase: letter.toUpperCase(),
}),
letters
);
const result = await concat.call(null, specimens);
assert.equal(result, "aAbBcC");
});
});

View File

@@ -0,0 +1,42 @@
import assert from "node:assert";
import { describe, test } from "node:test";
import { DeepObjectTree, Tree } from "../../src/internal.js";
import mergeDeep from "../../src/operations/deepMerge.js";
describe("mergeDeep", () => {
test("can merge deep", async () => {
const fixture = mergeDeep(
new DeepObjectTree({
a: {
b: 0, // Will be obscured by `b` below
c: {
d: 2,
},
},
}),
new DeepObjectTree({
a: {
b: 1,
c: {
e: 3,
},
f: 4,
},
})
);
assert.deepEqual(await Tree.plain(fixture), {
a: {
b: 1,
c: {
d: 2,
e: 3,
},
f: 4,
},
});
// Parent of a subvalue is the merged tree
const a = await fixture.get("a");
assert.equal(a.parent, fixture);
});
});

View File

@@ -0,0 +1,24 @@
import assert from "node:assert";
import { describe, test } from "node:test";
import { Tree } from "../../src/internal.js";
import deepReverse from "../../src/operations/deepReverse.js";
describe("deepReverse", () => {
test("reverses keys at all levels of a tree", async () => {
const tree = {
a: 1,
b: {
c: 2,
d: 3,
},
};
const reversed = deepReverse.call(null, tree);
assert.deepEqual(await Tree.plain(reversed), {
b: {
d: 3,
c: 2,
},
a: 1,
});
});
});

View File

@@ -0,0 +1,22 @@
import assert from "node:assert";
import { describe, test } from "node:test";
import { Tree } from "../../src/internal.js";
import deepTake from "../../src/operations/deepTake.js";
describe("deepTake", () => {
test("traverses deeply and returns a limited number of items", async () => {
const tree = {
a: 1,
b: {
c: 2,
d: {
e: 3,
},
f: 4,
},
g: 5,
};
const result = await deepTake(tree, 4);
assert.deepEqual(await Tree.plain(result), [1, 2, 3, 4]);
});
});

View File

@@ -0,0 +1,28 @@
import assert from "node:assert";
import { describe, test } from "node:test";
import deepValues from "../../src/operations/deepValues.js";
describe("deepValues", () => {
test("returns in-order array of a tree's values", async () => {
const tree = {
a: 1,
b: {
c: 2,
d: {
e: 3,
},
},
f: {
g: 4,
},
};
const values = await deepValues(tree);
assert.deepEqual(values, [1, 2, 3, 4]);
});
test("returns in-order array of values in nested arrays", async () => {
const tree = [1, [2, 3], 4];
const values = await deepValues(tree);
assert.deepEqual(values, [1, 2, 3, 4]);
});
});

View File

@@ -0,0 +1,23 @@
import assert from "node:assert";
import { describe, test } from "node:test";
import { ObjectTree } from "../../src/internal.js";
import deepValuesIterator from "../../src/operations/deepValuesIterator.js";
describe("deepValuesIterator", () => {
test("returns an iterator of a tree's deep values", async () => {
const tree = new ObjectTree({
a: 1,
b: 2,
more: {
c: 3,
d: 4,
},
});
const values = [];
// The tree will be shallow, but we'll ask to expand the values.
for await (const value of deepValuesIterator(tree, { expand: true })) {
values.push(value);
}
assert.deepEqual(values, [1, 2, 3, 4]);
});
});

View File

@@ -0,0 +1,54 @@
import assert from "node:assert";
import { describe, test } from "node:test";
import { Tree } from "../../src/internal.js";
import group from "../../src/operations/group.js";
describe("group transform", () => {
test("groups an array using a group key function", async () => {
const fonts = [
{ name: "Aboreto", tags: ["Sans Serif"] },
{ name: "Albert Sans", tags: ["Geometric", "Sans Serif"] },
{ name: "Alegreya", tags: ["Serif"] },
{ name: "Work Sans", tags: ["Grotesque", "Sans Serif"] },
];
const tree = Tree.from(fonts);
const grouped = await group(tree, (value, key, tree) => value.tags);
assert.deepEqual(await Tree.plain(grouped), {
Geometric: [{ name: "Albert Sans", tags: ["Geometric", "Sans Serif"] }],
Grotesque: [{ name: "Work Sans", tags: ["Grotesque", "Sans Serif"] }],
"Sans Serif": [
{ name: "Aboreto", tags: ["Sans Serif"] },
{ name: "Albert Sans", tags: ["Geometric", "Sans Serif"] },
{ name: "Work Sans", tags: ["Grotesque", "Sans Serif"] },
],
Serif: [{ name: "Alegreya", tags: ["Serif"] }],
});
});
test("groups an object using a group key function", async () => {
const fonts = {
Aboreto: { tags: ["Sans Serif"] },
"Albert Sans": { tags: ["Geometric", "Sans Serif"] },
Alegreya: { tags: ["Serif"] },
"Work Sans": { tags: ["Grotesque", "Sans Serif"] },
};
const tree = Tree.from(fonts);
const grouped = await group(tree, (value, key, tree) => value.tags);
assert.deepEqual(await Tree.plain(grouped), {
Geometric: {
"Albert Sans": { tags: ["Geometric", "Sans Serif"] },
},
Grotesque: {
"Work Sans": { tags: ["Grotesque", "Sans Serif"] },
},
"Sans Serif": {
Aboreto: { tags: ["Sans Serif"] },
"Albert Sans": { tags: ["Geometric", "Sans Serif"] },
"Work Sans": { tags: ["Grotesque", "Sans Serif"] },
},
Serif: {
Alegreya: { tags: ["Serif"] },
},
});
});
});

View File

@@ -0,0 +1,17 @@
import assert from "node:assert";
import { describe, test } from "node:test";
import { Tree } from "../../src/internal.js";
import invokeFunctions from "../../src/operations/invokeFunctions.js";
describe("invokeFunctions", () => {
test("invokes function values, leaves other values as is", async () => {
const fixture = invokeFunctions({
a: 1,
b: () => 2,
});
assert.deepEqual(await Tree.plain(fixture), {
a: 1,
b: 2,
});
});
});

View File

@@ -0,0 +1,82 @@
import assert from "node:assert";
import { describe, test } from "node:test";
import { ObjectTree, Tree } from "../../src/internal.js";
import keyFunctionsForExtensions from "../../src/operations/keyFunctionsForExtensions.js";
import map from "../../src/operations/map.js";
describe("keyMapsForExtensions", () => {
test("returns key functions that pass a matching key through", async () => {
const { inverseKey, key } = keyFunctionsForExtensions({
sourceExtension: ".txt",
});
assert.equal(await inverseKey("file.txt"), "file.txt");
assert.equal(await inverseKey("file.txt/"), "file.txt");
assert.equal(await key("file.txt"), "file.txt");
assert.equal(await key("file.txt/"), "file.txt/");
assert.equal(await inverseKey("file.foo"), undefined);
assert.equal(await key("file.foo"), undefined);
});
test("returns key functions that can map extensions", async () => {
const { inverseKey, key } = keyFunctionsForExtensions({
resultExtension: ".json",
sourceExtension: ".md",
});
assert.equal(await inverseKey("file.json"), "file.md");
assert.equal(await inverseKey("file.json/"), "file.md");
assert.equal(await key("file.md"), "file.json");
assert.equal(await key("file.md/"), "file.json/");
assert.equal(await inverseKey("file.foo"), undefined);
assert.equal(await key("file.foo"), undefined);
});
test("key functions can handle a slash as an explicit extension", async () => {
const { inverseKey, key } = keyFunctionsForExtensions({
resultExtension: ".html",
sourceExtension: "/",
});
assert.equal(await inverseKey("file.html"), "file/");
assert.equal(await inverseKey("file.html/"), "file/");
assert.equal(await key("file"), undefined);
assert.equal(await key("file/"), "file.html");
});
test("works with map to handle keys that end in a given resultExtension", async () => {
const files = new ObjectTree({
"file1.txt": "will be mapped",
file2: "won't be mapped",
"file3.foo": "won't be mapped",
});
const { inverseKey, key } = keyFunctionsForExtensions({
sourceExtension: ".txt",
});
const fixture = map(files, {
inverseKey,
key,
value: (sourceValue, sourceKey, tree) => sourceValue.toUpperCase(),
});
assert.deepEqual(await Tree.plain(fixture), {
"file1.txt": "WILL BE MAPPED",
});
});
test("works with map to change a key's resultExtension", async () => {
const files = new ObjectTree({
"file1.txt": "will be mapped",
file2: "won't be mapped",
"file3.foo": "won't be mapped",
});
const { inverseKey, key } = keyFunctionsForExtensions({
resultExtension: ".upper",
sourceExtension: ".txt",
});
const fixture = map(files, {
inverseKey,
key,
value: (sourceValue, sourceKey, tree) => sourceValue.toUpperCase(),
});
assert.deepEqual(await Tree.plain(fixture), {
"file1.upper": "WILL BE MAPPED",
});
});
});

View File

@@ -0,0 +1,204 @@
import assert from "node:assert";
import { describe, test } from "node:test";
import FunctionTree from "../../src/drivers/FunctionTree.js";
import { DeepObjectTree, ObjectTree, Tree } from "../../src/internal.js";
import map from "../../src/operations/map.js";
import * as trailingSlash from "../../src/trailingSlash.js";
describe("map", () => {
test("returns identity graph if no key or value function is supplied", async () => {
const tree = {
a: "letter a",
b: "letter b",
};
const mapped = map(tree, {});
assert.deepEqual(await Tree.plain(mapped), {
a: "letter a",
b: "letter b",
});
});
test("maps values", async () => {
const tree = new ObjectTree({
a: "letter a",
b: "letter b",
c: undefined, // Won't be mapped
});
const mapped = map(tree, {
value: (sourceValue, sourceKey, innerTree) => {
assert(sourceKey === "a" || sourceKey === "b");
assert.equal(innerTree, tree);
return sourceValue.toUpperCase();
},
});
assert.deepEqual(await Tree.plain(mapped), {
a: "LETTER A",
b: "LETTER B",
c: undefined,
});
});
test("interprets a single function argument as the value function", async () => {
const tree = {
a: "letter a",
b: "letter b",
};
const uppercaseValues = map(tree, (sourceValue, sourceKey, tree) => {
assert(sourceKey === "a" || sourceKey === "b");
return sourceValue.toUpperCase();
});
assert.deepEqual(await Tree.plain(uppercaseValues), {
a: "LETTER A",
b: "LETTER B",
});
});
test("maps keys using key and inverseKey", async () => {
const tree = {
a: "letter a",
b: "letter b",
};
const underscoreKeys = map(tree, {
key: addUnderscore,
inverseKey: removeUnderscore,
});
assert.deepEqual(await Tree.plain(underscoreKeys), {
_a: "letter a",
_b: "letter b",
});
});
test("maps keys and values", async () => {
const tree = {
a: "letter a",
b: "letter b",
};
const underscoreKeysUppercaseValues = map(tree, {
key: addUnderscore,
inverseKey: removeUnderscore,
value: async (sourceValue, sourceKey, tree) => sourceValue.toUpperCase(),
});
assert.deepEqual(await Tree.plain(underscoreKeysUppercaseValues), {
_a: "LETTER A",
_b: "LETTER B",
});
});
test("a shallow map is applied to async subtrees too", async () => {
const tree = {
a: "letter a",
more: {
b: "letter b",
},
};
const underscoreKeys = map(tree, {
key: async (sourceKey, tree) => `_${sourceKey}`,
inverseKey: async (resultKey, tree) => resultKey.slice(1),
value: async (sourceValue, sourceKey, tree) => sourceKey,
});
assert.deepEqual(await Tree.plain(underscoreKeys), {
_a: "a",
_more: "more",
});
});
test("value can provide a default key and inverse key functions", async () => {
const uppercase = (s) => s.toUpperCase();
uppercase.key = addUnderscore;
uppercase.inverseKey = removeUnderscore;
const tree = {
a: "letter a",
b: "letter b",
};
const mapped = map(tree, uppercase);
assert.deepEqual(await Tree.plain(mapped), {
_a: "LETTER A",
_b: "LETTER B",
});
});
test("deep maps values", async () => {
const tree = new DeepObjectTree({
a: "letter a",
more: {
b: "letter b",
},
});
const uppercaseValues = map(tree, {
deep: true,
value: (sourceValue, sourceKey, tree) => sourceValue.toUpperCase(),
});
assert.deepEqual(await Tree.plain(uppercaseValues), {
a: "LETTER A",
more: {
b: "LETTER B",
},
});
});
test("deep maps leaf keys", async () => {
const tree = new DeepObjectTree({
a: "letter a",
more: {
b: "letter b",
},
});
const underscoreKeys = map(tree, {
deep: true,
key: addUnderscore,
inverseKey: removeUnderscore,
});
assert.deepEqual(await Tree.plain(underscoreKeys), {
_a: "letter a",
more: {
_b: "letter b",
},
});
});
test("deep maps leaf keys and values", async () => {
const tree = new DeepObjectTree({
a: "letter a",
more: {
b: "letter b",
},
});
const underscoreKeysUppercaseValues = map(tree, {
deep: true,
key: addUnderscore,
inverseKey: removeUnderscore,
value: async (sourceValue, sourceKey, tree) => sourceValue.toUpperCase(),
});
assert.deepEqual(await Tree.plain(underscoreKeysUppercaseValues), {
_a: "LETTER A",
more: {
_b: "LETTER B",
},
});
});
test("needsSourceValue can be set to false in cases where the value isn't necessary", async () => {
let flag = false;
const tree = new FunctionTree(() => {
flag = true;
}, ["a", "b", "c"]);
const mapped = map(tree, {
needsSourceValue: false,
value: () => "X",
});
assert.deepEqual(await Tree.plain(mapped), {
a: "X",
b: "X",
c: "X",
});
assert(!flag);
});
});
function addUnderscore(key) {
return `_${key}`;
}
function removeUnderscore(key) {
return trailingSlash.has(key) ? key : key.slice(1);
}

View File

@@ -0,0 +1,64 @@
import assert from "node:assert";
import { describe, test } from "node:test";
import { DeepObjectTree, ObjectTree, Tree } from "../../src/internal.js";
import merge from "../../src/operations/merge.js";
import * as symbols from "../../src/symbols.js";
describe("merge", () => {
test("performs a shallow merge", async () => {
const fixture = merge(
{
a: 1,
// Will be obscured by `b` that follows
b: {
c: 2,
},
},
{
b: {
d: 3,
},
e: {
f: 4,
},
}
);
assert.deepEqual(await Tree.plain(fixture), {
a: 1,
b: {
d: 3,
},
e: {
f: 4,
},
});
// Merge is shallow, and last tree wins, so `b/c` doesn't exist
const c = await Tree.traverse(fixture, "b", "c");
assert.equal(c, undefined);
// Parent of a subvalue is the merged tree
const b = await fixture.get("b");
assert.equal(b[symbols.parent], fixture);
});
test("subtree can overwrite a leaf node", async () => {
const fixture = merge(
new ObjectTree({
a: 1,
}),
new DeepObjectTree({
a: {
b: 2,
},
})
);
assert.deepEqual([...(await fixture.keys())], ["a/"]);
assert.deepEqual(await Tree.plain(fixture), {
a: {
b: 2,
},
});
});
});

View File

@@ -0,0 +1,25 @@
import assert from "node:assert";
import { describe, test } from "node:test";
import { DeepObjectTree, Tree } from "../../src/internal.js";
import regExpKeys from "../../src/operations/regExpKeys.js";
describe("regExpKeys", () => {
test("matches keys using regular expressions", async () => {
const fixture = await regExpKeys(
new DeepObjectTree({
a: true,
"b.*": true,
c: {
d: true,
"e*": true,
},
})
);
assert(await Tree.traverse(fixture, "a"));
assert(!(await Tree.traverse(fixture, "alice")));
assert(await Tree.traverse(fixture, "bob"));
assert(await Tree.traverse(fixture, "brenda"));
assert(await Tree.traverse(fixture, "c", "d"));
assert(await Tree.traverse(fixture, "c", "eee"));
});
});

View File

@@ -0,0 +1,23 @@
import assert from "node:assert";
import { describe, test } from "node:test";
import { Tree } from "../../src/internal.js";
import reverse from "../../src/operations/reverse.js";
describe("reverse", () => {
test("reverses a tree's top-level keys", async () => {
const tree = {
a: "A",
b: "B",
c: "C",
};
const reversed = reverse.call(null, tree);
// @ts-ignore
assert.deepEqual(Array.from(await reversed.keys()), ["c", "b", "a"]);
// @ts-ignore
assert.deepEqual(await Tree.plain(reversed), {
c: "C",
b: "B",
a: "A",
});
});
});

View File

@@ -0,0 +1,25 @@
import assert from "node:assert";
import { describe, test } from "node:test";
import { ObjectTree } from "../../src/internal.js";
import scope from "../../src/operations/scope.js";
describe("scope", () => {
test("gets the first defined value from the scope trees", async () => {
const outer = new ObjectTree({
a: 1,
b: 2,
});
const inner = new ObjectTree({
a: 3,
});
inner.parent = outer;
const innerScope = scope(inner);
assert.deepEqual([...(await innerScope.keys())], ["a", "b"]);
// Inner tree has precedence
assert.equal(await innerScope.get("a"), 3);
// If tree doesn't have value, finds value from parent
assert.equal(await innerScope.get("b"), 2);
assert.equal(await innerScope.get("c"), undefined);
assert.deepEqual(innerScope.trees, [inner, outer]);
});
});

View File

@@ -0,0 +1,48 @@
import assert from "node:assert";
import { describe, test } from "node:test";
import { Tree } from "../../src/internal.js";
import sort from "../../src/operations/sort.js";
describe("sort", () => {
test("sorts keys using default sort order", async () => {
const tree = Tree.from({
file10: null,
file1: null,
file9: null,
});
const sorted = sort(tree);
assert.deepEqual(Array.from(await sorted.keys()), [
"file1",
"file10",
"file9",
]);
});
test("invokes a comparison function", async () => {
const tree = Tree.from({
b: 2,
c: 3,
a: 1,
});
// Reverse order
const compare = (a, b) => (a > b ? -1 : a < b ? 1 : 0);
const sorted = sort(tree, { compare });
assert.deepEqual(Array.from(await sorted.keys()), ["c", "b", "a"]);
});
test("invokes a sortKey function", async () => {
const tree = {
Alice: { age: 48 },
Bob: { age: 36 },
Carol: { age: 42 },
};
const sorted = await sort(tree, {
sortKey: async (key, tree) => Tree.traverse(tree, key, "age"),
});
assert.deepEqual(Array.from(await sorted.keys()), [
"Bob",
"Carol",
"Alice",
]);
});
});

View File

@@ -0,0 +1,20 @@
import assert from "node:assert";
import { describe, test } from "node:test";
import { Tree } from "../../src/internal.js";
import take from "../../src/operations/take.js";
describe("take", () => {
test("limits the number of keys to the indicated count", async () => {
const tree = {
a: 1,
b: 2,
c: 3,
d: 4,
};
const result = await take(tree, 2);
assert.deepEqual(await Tree.plain(result), {
a: 1,
b: 2,
});
});
});

View File

@@ -0,0 +1,36 @@
import assert from "node:assert";
import { describe, test } from "node:test";
import { add, has, remove, toggle } from "../src/trailingSlash.js";
describe("trailingSlash", () => {
test("add adds a trailing slash to a string key for a truthy value", () => {
assert.equal(add("key"), "key/");
assert.equal(add("key/"), "key/");
assert.equal(add(1), 1);
});
test("has returns true if a string key has a trailing slash", () => {
assert.equal(has("key/"), true);
assert.equal(has("key"), false);
assert.equal(has(1), false);
});
test("remove removes a trailing slash from a string key", () => {
assert.equal(remove("key/"), "key");
assert.equal(remove("key"), "key");
assert.equal(remove(1), 1);
});
test("toggle removes a slash if present, adds one if not", () => {
assert.equal(toggle("key/"), "key");
assert.equal(toggle("key"), "key/");
assert.equal(toggle(1), 1);
});
test("toggle can force toggling on or off", () => {
assert.equal(toggle("key/", false), "key");
assert.equal(toggle("key/", true), "key/");
assert.equal(toggle("key", false), "key");
assert.equal(toggle("key", true), "key/");
});
});

View File

@@ -0,0 +1,140 @@
import assert from "node:assert";
import { describe, test } from "node:test";
import { ObjectTree } from "../src/internal.js";
import * as symbols from "../src/symbols.js";
import * as utilities from "../src/utilities.js";
describe("utilities", () => {
test("box returns a boxed value", () => {
const string = "string";
const stringObject = utilities.box(string);
assert(stringObject instanceof String);
assert.equal(stringObject, string);
const number = 1;
const numberObject = utilities.box(number);
assert(numberObject instanceof Number);
assert.equal(numberObject, number);
const boolean = true;
const booleanObject = utilities.box(boolean);
assert(booleanObject instanceof Boolean);
assert.equal(booleanObject, boolean);
const object = {};
const boxedObject = utilities.box(object);
assert.equal(boxedObject, object);
});
test("getRealmObjectPrototype returns the object's root prototype", () => {
const object = {};
const proto = utilities.getRealmObjectPrototype(object);
assert.equal(proto, Object.prototype);
});
test("isPlainObject returns true if the object is a plain object", () => {
assert.equal(utilities.isPlainObject({}), true);
assert.equal(utilities.isPlainObject(new Object()), true);
assert.equal(utilities.isPlainObject(Object.create(null)), true);
class Foo {}
assert.equal(utilities.isPlainObject(new Foo()), false);
});
test("keysFromPath() returns the keys from a slash-separated path", () => {
assert.deepEqual(utilities.keysFromPath(""), []);
assert.deepEqual(utilities.keysFromPath("/"), []);
assert.deepEqual(utilities.keysFromPath("a/b/c"), ["a/", "b/", "c"]);
assert.deepEqual(utilities.keysFromPath("a/b/c/"), ["a/", "b/", "c/"]);
assert.deepEqual(utilities.keysFromPath("/foo/"), ["foo/"]);
assert.deepEqual(utilities.keysFromPath("a///b"), ["a/", "b"]);
});
test("naturalOrder compares strings in natural order", () => {
const strings = ["file10", "file1", "file9"];
strings.sort(utilities.naturalOrder);
assert.deepEqual(strings, ["file1", "file9", "file10"]);
});
test("pathFromKeys() returns a slash-separated path from keys", () => {
assert.equal(utilities.pathFromKeys([]), "");
assert.equal(utilities.pathFromKeys(["a", "b", "c"]), "a/b/c");
assert.equal(utilities.pathFromKeys(["a/", "b/", "c"]), "a/b/c");
});
test("pipeline applies a series of functions to a value", async () => {
const addOne = (n) => n + 1;
const double = (n) => n * 2;
const square = (n) => n * n;
const result = await utilities.pipeline(1, addOne, double, square);
assert.equal(result, 16);
});
test("setParent sets a child's parent", () => {
const parent = new ObjectTree({});
// Set [symbols.parent] on a plain object.
const object = {};
utilities.setParent(object, parent);
assert.equal(object[symbols.parent], parent);
// Leave [symbols.parent] alone if it's already set.
const childWithParent = {
[symbols.parent]: "parent",
};
utilities.setParent(childWithParent, parent);
assert.equal(childWithParent[symbols.parent], "parent");
// Set `parent` on a tree.
const tree = new ObjectTree({});
utilities.setParent(tree, parent);
assert.equal(tree.parent, parent);
// Leave `parent` alone if it's already set.
const treeWithParent = new ObjectTree({});
treeWithParent.parent = "parent";
utilities.setParent(treeWithParent, parent);
assert.equal(treeWithParent.parent, "parent");
});
test("toPlainValue returns the plainest representation of an object", async () => {
class User {
constructor(name) {
this.name = name;
}
}
assert.equal(await utilities.toPlainValue(1), 1);
assert.equal(await utilities.toPlainValue("string"), "string");
assert.deepEqual(await utilities.toPlainValue({ a: 1 }), { a: 1 });
assert.equal(
await utilities.toPlainValue(new TextEncoder().encode("bytes")),
"bytes"
);
// ArrayBuffer with non-printable characters should be returned as base64
assert.equal(
await utilities.toPlainValue(new Uint8Array([1, 2, 3]).buffer),
"AQID"
);
assert.equal(await utilities.toPlainValue(async () => "result"), "result");
assert.deepEqual(await utilities.toPlainValue(new User("Alice")), {
name: "Alice",
});
});
test("toString returns the value of an object's `toString` method", () => {
const object = {
toString: () => "text",
};
assert.equal(utilities.toString(object), "text");
});
test("toString returns null for an object with no useful `toString`", () => {
const object = {};
assert.equal(utilities.toString(object), null);
});
test("toString decodes an ArrayBuffer as UTF-8", () => {
const arrayBuffer = new TextEncoder().encode("text").buffer;
assert.equal(utilities.toString(arrayBuffer), "text");
});
});