Sfoglia il codice sorgente

feat: Mixins class

Sv443 4 settimane fa
parent
commit
507583177d
6 ha cambiato i file con 279 aggiunte e 0 eliminazioni
  1. 5 0
      .changeset/flat-berries-walk.md
  2. 1 0
      README-summary.md
  3. 1 0
      README.md
  4. 124 0
      docs.md
  5. 147 0
      lib/Mixins.ts
  6. 1 0
      lib/index.ts

+ 5 - 0
.changeset/flat-berries-walk.md

@@ -0,0 +1,5 @@
+---
+"@sv443-network/userutils": minor
+---
+
+Added `Mixins` class for allowing multiple sources to modify values in a controlled way

+ 1 - 0
README-summary.md

@@ -60,6 +60,7 @@ View the documentation of previous major releases:
     - [`DataStore`](https://github.com/Sv443-Network/UserUtils/blob/main/docs.md#datastore) - class that manages a hybrid sync & async persistent JSON database, including data migration
     - [`DataStoreSerializer`](https://github.com/Sv443-Network/UserUtils/blob/main/docs.md#datastoreserializer) - class for importing & exporting data of multiple DataStore instances, including compression, checksumming and running migrations
     - [`Dialog`](https://github.com/Sv443-Network/UserUtils/blob/main/docs.md#dialog) - class for creating custom modal dialogs with a promise-based API and a generic, default style
+    - [`Mixins`](https://github.com/Sv443-Network/UserUtils/blob/main/docs.md#mixins) - class for creating mixin functions that allow multiple sources to modify a target value in a highly flexible way
     - [`NanoEmitter`](https://github.com/Sv443-Network/UserUtils/blob/main/docs.md#nanoemitter) - tiny event emitter class with a focus on performance and simplicity (based on [nanoevents](https://npmjs.com/package/nanoevents))
     - [`Debouncer`](https://github.com/Sv443-Network/UserUtils/blob/main/docs.md#debouncer) - class for debouncing function calls with a given timeout
     - [`debounce()`](https://github.com/Sv443-Network/UserUtils/blob/main/docs.md#debounce) - function wrapper for the Debouncer class for easier usage

+ 1 - 0
README.md

@@ -63,6 +63,7 @@ View the documentation of previous major releases:
     - [`DataStore`](./docs.md#datastore) - class that manages a hybrid sync & async persistent JSON database, including data migration
     - [`DataStoreSerializer`](./docs.md#datastoreserializer) - class for importing & exporting data of multiple DataStore instances, including compression, checksumming and running migrations
     - [`Dialog`](./docs.md#dialog) - class for creating custom modal dialogs with a promise-based API and a generic, default style
+    - [`Mixins`](./docs.md#mixins) - class for creating mixin functions that allow multiple sources to modify a target value in a highly flexible way
     - [`NanoEmitter`](./docs.md#nanoemitter) - tiny event emitter class with a focus on performance and simplicity (based on [nanoevents](https://npmjs.com/package/nanoevents))
     - [`Debouncer`](./docs.md#debouncer) - class for debouncing function calls with a given timeout
     - [`debounce()`](./docs.md#debounce) - function wrapper for the Debouncer class for easier usage

+ 124 - 0
docs.md

@@ -52,6 +52,7 @@ For submitting bug reports or feature requests, please use the [GitHub issue tra
     - [`DataStore`](#datastore) - class that manages a hybrid sync & async persistent JSON database, including data migration
     - [`DataStoreSerializer`](#datastoreserializer) - class for importing & exporting data of multiple DataStore instances, including compression, checksumming and running migrations
     - [`Dialog`](#dialog) - class for creating custom modal dialogs with a promise-based API and a generic, default style
+    - [`Mixins`](#mixins) - class for creating mixin functions that allow multiple sources to modify a target value in a highly flexible way
     - [`NanoEmitter`](#nanoemitter) - tiny event emitter class with a focus on performance and simplicity (based on [nanoevents](https://npmjs.com/package/nanoevents))
     - [`Debouncer`](#debouncer) - class for debouncing function calls with a given timeout
     - [`debounce()`](#debounce) - function wrapper for the Debouncer class for easier usage
@@ -1866,6 +1867,129 @@ fooDialog.open();
 
 <br>
 
+### Mixins
+Signature:  
+```ts
+new Mixins<TMixinMap extends Record<string, (arg: any, ctx?: any) => any>>(defaultConfigOverrides?: Partial<MixinConfig>): Mixins<TMixinMap>
+```
+  
+A class that provides a way to apply multiple mixin functions to any value, which is a convenient way of letting multiple sources modify the same value in a controlled way.  
+  
+This could be used for a plugin system, for example, where multiple plugins can modify the same object or value.  
+Another more day-to-day example would be a configuration object that is modified by multiple sources with different and varying priorities and conditions.  
+  
+The `TMixinMap` generic is used to define the mixin functions that can be applied to the value.  
+It needs to be an object where the keys are the mixin names and the values are functions that take the value to be modified as the first argument and an optional context object as the second argument and returns the modified value.  
+**Important:** the argument and return type need to be the same, otherwise TypeScript will get confused.  
+  
+The default configuration object can be overridden by passing an object with the same properties as the default configuration object.  
+Unless overridden in the [`resolve()`](#mixinsresolve) method, the default configuration object will be used.  
+If no value is provided, the priority will default to 0.  
+  
+The properties of the MixinConfig object are:
+| Property | Description |
+| :-- | :-- |
+| `priority: number` | The priority of the mixin function. Higher numbers will be applied first. Negative values are also allowed. Defaults to 0. |
+| `stopPropagation: boolean` | If set to `true`, the mixin function will prevent all upcoming mixin functions from being called. Defaults to `false`. |
+| `signal: AbortSignal` | An optional AbortSignal that can be used to stop applying the mixin function. |
+  
+### Methods:
+#### `Mixins.resolve()`
+Signature: `resolve(mixinKey: string, inputValue: any, inputCtx?: any): TArg`  
+Applies all mixin functions that were registered with [`add()`](#mixinsadd) for the given mixin key to resolve the input value.  
+Goes in order of highest to lowest priority and returns the resolved value, which is of the same type as the input value.  
+If no mixin functions are registered for the given key, the input value will be returned unchanged.  
+
+<br>
+
+#### `Mixins.add()`
+Signature: `add(mixinKey: string, mixinFn: (arg: any, ctx?: any) => any, config?: Partial<MixinConfig>): () => void`  
+Registers a mixin function for the given key.  
+The function will be called with the input value (possibly modified by previous mixins) and possibly a context object.  
+When a value for the context parameter is defined in the main generic of the `Mixins` class, the ctx parameter will be required. Otherwise it should always be unspecified.  
+Returns a function that can be called to remove the mixin function from the list of registered mixins.
+
+<br>
+
+#### `Mixins.list()`
+Signature: `list(): Array<{ key: string; } & MixinConfig>`  
+Returns an array of objects that contain the mixin keys and their configuration objects.  
+Doesn't return the mixin functions themselves.
+
+<br>
+
+<details><summary><b>Example - click to view</b></summary>
+
+```ts
+import { Mixins } from "@sv443-network/userutils";
+
+
+const mathMixins = new Mixins<{
+  foo: (val: number) => number;
+  bar: (v: string, ctx: { barGlobal: number }) => string;
+}>();
+
+
+// foo:
+
+// internal function:
+export function calcFoo(fooVal: number) {
+  // order of operations:
+  // 1. fooVal ** 2
+  // 2. / 2 (critical prio)
+  // 3. + 1 (added first)
+  // 4. * 2 (added second)
+  return mathMixins.use("foo", fooVal ** 2);
+}
+
+// mixin from source 1:
+mathMixins.add("foo", (val) => {
+  return val + 1;
+});
+
+// mixin from source 2:
+mathMixins.add("foo", (val) => {
+  return val * 2;
+});
+
+// mixin from source 3 with critical priority:
+mathMixins.add("foo", (val) => {
+  return val / 2;
+}, {
+  // use highest possible priority (highly discouraged unless it's absolutely necessary):
+  priority: Number.MAX_SAFE_INTEGER,
+});
+
+
+// bar:
+
+const barGlobal = 1337;
+
+// internal function:
+export function getBar(barVal: string) {
+  // order of operations:
+  // 1. barVal + "-" + barGlobal (highest priority & has stopPropagation)
+  // result: "Hello-1337"
+  return mathMixins.use("bar", barVal, { barGlobal });
+}
+
+// mixin from source 1:
+mathMixins.add("bar", (val) => {
+  return val + " this is ignored";
+});
+
+// mixin from source 2:
+mathMixins.add("bar", (val, ctx) => {
+  return val + "-" + ctx.barGlobal;
+}, {
+  priority: 1,
+  stopPropagation: true,
+});
+```
+</details>
+
+<br>
+
 ### NanoEmitter
 Signature:  
 ```ts

+ 147 - 0
lib/Mixins.ts

@@ -0,0 +1,147 @@
+/**
+ * @module lib/Mixins
+ * Allows for defining and applying mixin functions to allow multiple sources to modify a value in a controlled way.
+ */
+
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+/** Full mixin object as it is stored in the instance's mixin array. */
+export type MixinObj<TArg, TCtx> = {
+  /** The public identifier key (purpose) of the mixin */
+  key: string;
+  /** The mixin function */
+  fn: (arg: TArg, ctx?: TCtx) => TArg;
+} & MixinConfig;
+
+/** Configuration object for a mixin function */
+export type MixinConfig = {
+  /** The higher, the earlier the mixin will be applied. Supports negative numbers too. 0 by default. */
+  priority: number;
+  /** If true, no further mixins will be applied after this one. */
+  stopPropagation: boolean;
+  /** If set, the mixin will only be applied if the given signal is not aborted. */
+  signal?: AbortSignal;
+}
+
+/**
+ * The mixin class allows for defining and applying mixin functions to allow multiple sources to modify values in a controlled way.  
+ * Mixins are identified via their string key and can be added with {@linkcode add()}  
+ * When calling {@linkcode resolve()}, all registered mixin functions with the same key will be applied to the input value in the order of their priority.  
+ * If a mixin has the stopPropagation flag set to true, no further mixins will be applied after it.  
+ *   
+ * @example ```ts
+ * const mathMixins = new Mixins<{
+ *   foo: (val: number) => number;
+ *   bar: (val: string, ctx: { baz: number }) => string;
+ * }>();
+ * 
+ * // will be applied second due to base priority of 0:
+ * mathMixins.add("foo", (val) => val * 2);
+ * // this mixin will be applied first, even though the above one was called first:
+ * mathMixins.add("foo", (val) => val + 1, { priority: 1 });
+ * 
+ * function getFoo() {
+ *   // 1. start with 5 as the base value
+ *   // 2. add 1
+ *   // 3. multiply by 2
+ *   // result: 12
+ *   return mathMixins.resolve("foo", 5);
+ * }
+ * 
+ * 
+ * mathMixins.add("bar", (val, ctx) => `${val} ${btoa(ctx.baz)}`);
+ * mathMixins.add("bar", (val, ctx) => `${val}!`);
+ * 
+ * function getBar() {
+ *   // 1. start with "Hello" as the base value
+ *   // 2. append base64-encoded value in ctx.baz
+ *   // 3. append "!"
+ *   // result: "Hello d29ybGQ=!"
+ *   return mathMixins.resolve("bar", "Hello", { baz: "world" });
+ * }
+ * ```
+ */
+export class Mixins<TMixinMap extends Record<string, (arg: any, ctx?: any) => any>> {
+  protected mixins: MixinObj<any, any>[] = [];
+  protected readonly defaultConfig: MixinConfig;
+
+  /**
+   * Creates a new Mixins instance.
+   * @param defaultConfigOverrides An object to override the default configuration values for all mixin functions.
+   */
+  constructor(defaultConfigOverrides: Partial<MixinConfig> = {}) {
+    this.defaultConfig = {
+      priority: 0,
+      stopPropagation: false,
+      ...defaultConfigOverrides,
+    };
+  }
+
+  /**
+   * Adds a modifier function to the mixin with the given {@linkcode mixinKey}.
+   * @param mixinKey The key to identify the mixin function.
+   * @param mixinFn The function to be called to apply the mixin.
+   * @param config Configuration object to customize the mixin behavior.
+   * @returns Returns a cleanup function, to be called when this mixin is no longer needed.
+   */
+  public add<
+    TMixinKey extends string,
+    TArg extends Parameters<TMixinMap[TMixinKey]>[0],
+    TCtx extends Parameters<TMixinMap[TMixinKey]>[1],
+  >(
+    mixinKey: TMixinKey,
+    mixinFn: (arg: TArg, ...ctx: TCtx extends undefined ? [void] : [TCtx]) => TArg,
+    config: Partial<MixinConfig> = {}
+  ): () => void {
+    const mixin = {
+      key: mixinKey as string,
+      fn: mixinFn,
+      ...this.defaultConfig,
+      ...config,
+    } as MixinObj<TArg, TCtx>;
+    this.mixins.push(mixin);
+
+    const clean = (): void => {
+      this.mixins = this.mixins.filter((m) => m !== mixin);
+    };
+    config.signal?.addEventListener("abort", clean, { once: true });
+
+    return clean;
+  }
+
+  /** Returns a list of all added mixins with their keys and configuration objects, but not their functions */
+  public list(): ({ key: string; } & MixinConfig)[] {
+    return this.mixins.map(({ fn: _f, ...rest }) => rest);
+  }
+
+  /**
+   * Applies all mixins with the given key to the input value, respecting the priority and stopPropagation settings.  
+   * If additional context is set in the MixinMap, it will need to be passed as the third argument.  
+   * @returns The modified value after all mixins have been applied.
+   */
+  public resolve<
+    TMixinKey extends keyof TMixinMap,
+    TArg extends Parameters<TMixinMap[TMixinKey]>[0],
+    TCtx extends Parameters<TMixinMap[TMixinKey]>[1],
+  >(
+    mixinKey: TMixinKey,
+    inputValue: TArg,
+    ...inputCtx: TCtx extends undefined ? [void] : [TCtx]
+  ): TArg {
+    const mixins = this.mixins.filter((m) => m.key === mixinKey);
+    const sortedMixins = mixins.sort((a, b) => a.priority - b.priority);
+    let result = inputValue;
+    for(const mixin of sortedMixins) {
+      result = mixin.fn(result, ...inputCtx);
+      if(mixin.stopPropagation)
+        break;
+    }
+    return result;
+  }
+
+  /** Removes all mixins with the given key */
+  protected removeAll<TMixinKey extends keyof TMixinMap>(mixinKey: TMixinKey): void {
+    this.mixins.filter((m) => m.key === mixinKey);
+    this.mixins = this.mixins.filter((m) => m.key !== mixinKey);
+  }
+}

+ 1 - 0
lib/index.ts

@@ -14,6 +14,7 @@ export * from "./dom.js";
 export * from "./errors.js";
 export * from "./math.js";
 export * from "./misc.js";
+export * from "./Mixins.js";
 export * from "./NanoEmitter.js";
 export * from "./SelectorObserver.js";
 export * from "./translation.js";