@pistonite/pure 0.0.12 → 0.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pistonite/pure",
3
- "version": "0.0.12",
3
+ "version": "0.0.14",
4
4
  "description": "Pure TypeScript libraries for my projects",
5
5
  "homepage": "https://github.com/Pistonite/pure",
6
6
  "bugs": {
@@ -9,7 +9,8 @@
9
9
  "license": "MIT",
10
10
  "author": "Pistonight <pistonknight@outlook.com>",
11
11
  "files": [
12
- "src/**/*"
12
+ "src/**/*",
13
+ "!src/**/*.test.ts"
13
14
  ],
14
15
  "exports": {
15
16
  "./fs": "./src/fs/index.ts",
@@ -24,10 +25,11 @@
24
25
  "directory": "packages/pure"
25
26
  },
26
27
  "dependencies": {
28
+ "@types/file-saver": "^2.0.7",
27
29
  "denque": "2.1.0",
28
30
  "file-saver": "2.0.5"
29
31
  },
30
32
  "devDependencies": {
31
- "@types/file-saver": "^2.0.7"
33
+ "vitest": "^2.1.8"
32
34
  }
33
35
  }
package/src/pref/dark.ts CHANGED
@@ -50,12 +50,16 @@
50
50
  * @module
51
51
  */
52
52
 
53
+ import { persist } from "../sync/persist.ts";
53
54
  import { injectStyle } from "./injectStyle.ts";
54
55
 
55
- const KEY = "Pure.Dark";
56
-
57
- let dark = false;
58
- const subscribers: ((dark: boolean) => void)[] = [];
56
+ const dark = persist({
57
+ initial: false,
58
+ key: "Pure.Dark",
59
+ storage: localStorage,
60
+ serialize: (value) => (value ? "1" : ""),
61
+ deserialize: (value) => !!value,
62
+ });
59
63
 
60
64
  /**
61
65
  * Returns if dark mode is prefered in the browser environment
@@ -96,20 +100,6 @@ export type DarkOptions = {
96
100
  export const initDark = (options: DarkOptions = {}): void => {
97
101
  let _dark = options.initial || prefersDarkMode();
98
102
 
99
- if (options.persist) {
100
- const value = localStorage.getItem(KEY);
101
- if (value !== null) {
102
- _dark = !!value;
103
- }
104
- addDarkSubscriber((dark: boolean) => {
105
- localStorage.setItem(KEY, dark ? "1" : "");
106
- });
107
- } else {
108
- localStorage.removeItem(KEY);
109
- }
110
-
111
- setDark(_dark);
112
-
113
103
  const selector = options.selector ?? ":root";
114
104
  if (selector) {
115
105
  // notify immediately to update the style initially
@@ -117,6 +107,12 @@ export const initDark = (options: DarkOptions = {}): void => {
117
107
  updateStyle(dark, selector);
118
108
  }, true /* notify */);
119
109
  }
110
+
111
+ if (options.persist) {
112
+ dark.init(_dark);
113
+ } else {
114
+ dark.disable();
115
+ }
120
116
  };
121
117
 
122
118
  /**
@@ -130,55 +126,33 @@ export const initDark = (options: DarkOptions = {}): void => {
130
126
  * subsequence `setDark` calls will still persist the value.
131
127
  */
132
128
  export const clearPersistedDarkPerference = (): void => {
133
- localStorage.removeItem(KEY);
129
+ dark.clear();
134
130
  };
135
131
 
136
132
  /**
137
133
  * Gets the current value of dark mode
138
134
  */
139
- export const isDark = (): boolean => dark;
135
+ export const isDark = (): boolean => dark.get();
140
136
 
141
137
  /**
142
138
  * Set the value of dark mode
143
139
  */
144
140
  export const setDark = (value: boolean): void => {
145
- if (dark === value) {
146
- return;
147
- }
148
- dark = value;
149
- const len = subscribers.length;
150
- for (let i = 0; i < len; i++) {
151
- subscribers[i](dark);
152
- }
141
+ dark.set(value);
153
142
  };
154
143
  /**
155
- * Add a subscriber to dark mode changes
144
+ * Add a subscriber to dark mode changes and return a function to remove the subscriber
156
145
  *
157
146
  * If `notifyImmediately` is `true`, the subscriber will be called immediately with the current value
158
147
  */
159
148
  export const addDarkSubscriber = (
160
149
  subscriber: (dark: boolean) => void,
161
150
  notifyImmediately?: boolean,
162
- ): void => {
163
- subscribers.push(subscriber);
164
- if (notifyImmediately) {
165
- subscriber(dark);
166
- }
167
- };
168
-
169
- /**
170
- * Remove a subscriber from dark mode changes
171
- */
172
- export const removeDarkSubscriber = (
173
- subscriber: (dark: boolean) => void,
174
- ): void => {
175
- const index = subscribers.indexOf(subscriber);
176
- if (index >= 0) {
177
- subscribers.splice(index, 1);
178
- }
151
+ ): (() => void) => {
152
+ return dark.subscribe(subscriber, notifyImmediately);
179
153
  };
180
154
 
181
155
  const updateStyle = (dark: boolean, selector: string) => {
182
156
  const text = `${selector} { color-scheme: ${dark ? "dark" : "light"}; }`;
183
- injectStyle(KEY, text);
157
+ injectStyle("pure-pref-dark", text);
184
158
  };
@@ -53,12 +53,20 @@
53
53
  * @module
54
54
  */
55
55
 
56
- const KEY = "Pure.Locale";
56
+ import { persist } from "../sync/persist.ts";
57
57
 
58
58
  let supportedLocales: readonly string[] = [];
59
- let locale: string = "";
60
59
  let defaultLocale: string = "";
61
- const subscribers: ((locale: string) => void)[] = [];
60
+ const locale = persist<string>({
61
+ initial: "",
62
+ key: "Pure.Locale",
63
+ storage: localStorage,
64
+ serialize: (value) => value,
65
+ deserialize: (value) => {
66
+ const supported = convertToSupportedLocale(value);
67
+ return supported || null;
68
+ },
69
+ });
62
70
 
63
71
  /**
64
72
  * Use browser API to guess user's preferred locale
@@ -120,21 +128,10 @@ export const initLocale = <TLocale extends string>(
120
128
  }
121
129
  defaultLocale = options.default;
122
130
  if (options.persist) {
123
- const value = localStorage.getItem(KEY);
124
- if (value !== null) {
125
- const supported = convertToSupportedLocale(value);
126
- if (supported) {
127
- _locale = supported;
128
- }
129
- }
130
- addLocaleSubscriber((locale: string) => {
131
- localStorage.setItem(KEY, locale);
132
- });
131
+ locale.init(_locale);
133
132
  } else {
134
- localStorage.removeItem(KEY);
133
+ locale.disable();
135
134
  }
136
-
137
- setLocale(_locale);
138
135
  };
139
136
 
140
137
  /**
@@ -149,13 +146,11 @@ export const initLocale = <TLocale extends string>(
149
146
  * subsequence `setLocale` calls will still persist the value.
150
147
  */
151
148
  export const clearPersistedLocalePreference = (): void => {
152
- localStorage.removeItem(KEY);
149
+ locale.clear();
153
150
  };
154
151
 
155
152
  /** Get the current selected locale */
156
- export const getLocale = (): string => {
157
- return locale;
158
- };
153
+ export const getLocale = (): string => locale.get();
159
154
 
160
155
  /** Get the default locale when initialized */
161
156
  export const getDefaultLocale = (): string => {
@@ -172,14 +167,7 @@ export const setLocale = (newLocale: string): boolean => {
172
167
  if (!supported) {
173
168
  return false;
174
169
  }
175
- if (supported === locale) {
176
- return true;
177
- }
178
- locale = supported;
179
- const len = subscribers.length;
180
- for (let i = 0; i < len; i++) {
181
- subscribers[i](locale);
182
- }
170
+ locale.set(supported);
183
171
  return true;
184
172
  };
185
173
 
@@ -235,28 +223,16 @@ export const convertToSupportedLocaleOrDefault = (
235
223
  };
236
224
 
237
225
  /**
238
- * Add a subscriber to be notified when the locale changes
226
+ * Add a subscriber to be notified when the locale changes.
227
+ * Returns a function to remove the subscriber
239
228
  *
240
229
  * If `notifyImmediately` is `true`, the subscriber will be called immediately with the current locale
241
230
  */
242
231
  export const addLocaleSubscriber = (
243
232
  fn: (locale: string) => void,
244
233
  notifyImmediately?: boolean,
245
- ): void => {
246
- subscribers.push(fn);
247
- if (notifyImmediately) {
248
- fn(locale);
249
- }
250
- };
251
-
252
- /**
253
- * Remove a subscriber from locale changes
254
- */
255
- export const removeLocaleSubscriber = (fn: (locale: string) => void): void => {
256
- const index = subscribers.indexOf(fn);
257
- if (index !== -1) {
258
- subscribers.splice(index, 1);
259
- }
234
+ ): (() => void) => {
235
+ return locale.subscribe(fn, notifyImmediately);
260
236
  };
261
237
 
262
238
  /**
@@ -0,0 +1,233 @@
1
+ import { makePromise, type AwaitRet } from "./util.ts";
2
+
3
+ /**
4
+ * An async event wrapper that allows multiple calls in an interval
5
+ * to be batched together, and only call the underlying function once.
6
+ *
7
+ * Optionally, the output can be unbatched to match the inputs.
8
+ *
9
+ * ## Example
10
+ * The API is a lot like `debounce`, but with an additional `batch` function
11
+ * and an optional `unbatch` function.
12
+ * ```typescript
13
+ * import { batch } from "@pistonite/pure/sync";
14
+ *
15
+ * const execute = batch({
16
+ * fn: (n: number) => {
17
+ * console.log(n);
18
+ * },
19
+ * interval: 100,
20
+ * // batch receives all the inputs and returns a single input
21
+ * // here we just sums the inputs
22
+ * batch: (args: [number][]): [number] => [args.reduce((acc, [n]) => acc + n, 0)],
23
+ * });
24
+ *
25
+ * await execute(1); // logs 1 immediately
26
+ * const p1 = execute(2); // will be resolved at 100ms
27
+ * const p2 = execute(3); // will be resolved at 100ms
28
+ * await Promise.all([p1, p2]); // logs 5 after 100ms
29
+ * ```
30
+ *
31
+ * ## Unbatching
32
+ * The optional `unbatch` function allows the output to be unbatched,
33
+ * so the promises are resolved as if the underlying function is called
34
+ * directly.
35
+ *
36
+ * Note that unbatching is usually slow and not required.
37
+ *
38
+ * ```typescript
39
+ * import { batch } from "@pistonite/pure/sync";
40
+ *
41
+ * type Message = {
42
+ * id: number;
43
+ * payload: string;
44
+ * }
45
+ *
46
+ * const execute = batch({
47
+ * fn: (messages: Message[]): Message[] => {
48
+ * console.log(messages.length);
49
+ * return messages.map((m) => ({
50
+ * id: m.id,
51
+ * payload: m.payload + "out",
52
+ * }));
53
+ * },
54
+ * batch: (args: [Message[]][]): [Message[]] => {
55
+ * const out: Message[] = [];
56
+ * for (const [messages] of args) {
57
+ * out.push(...messages);
58
+ * }
59
+ * return [out];
60
+ * },
61
+ * unbatch: (inputs: [Message[]][], output: Message[]): Message[][] => {
62
+ * // not efficient, but just for demonstration
63
+ * const idToOutput = new Map();
64
+ * for (const o of output) {
65
+ * idToOutput.set(o.id, o);
66
+ * }
67
+ * return inputs.map(([messages]) => {
68
+ * return messages.map(({id}) => {
69
+ * return idToOutput.get(m.id)!;
70
+ * });
71
+ * });
72
+ * },
73
+ * interval: 100,
74
+ * });
75
+ *
76
+ * const r1 = await execute([{id: 1, payload: "a"}]); // logs 1 immediately
77
+ * // r1 is [ {id: 1, payload: "aout"} ]
78
+ *
79
+ * const p1 = execute([{id: 2, payload: "b"}]); // will be resolved at 100ms
80
+ * const p2 = execute([{id: 3, payload: "c"}]); // will be resolved at 100ms
81
+ *
82
+ * const r2 = await p2; // 2 is logged
83
+ * // r1 is [ {id: 2, payload: "bout"} ]
84
+ * const r3 = await p3; // nothing is logged, as it's already resolved
85
+ * // r2 is [ {id: 3, payload: "cout"} ]
86
+ *
87
+ * ```
88
+ *
89
+ */
90
+ export function batch<TFn extends (...args: any[]) => any>({
91
+ fn,
92
+ batch,
93
+ unbatch,
94
+ interval,
95
+ disregardExecutionTime,
96
+ }: BatchConstructor<TFn>) {
97
+ const impl = new BatchImpl(
98
+ fn,
99
+ batch,
100
+ unbatch,
101
+ interval,
102
+ !!disregardExecutionTime,
103
+ );
104
+ return (...args: Parameters<TFn>) => impl.invoke(...args);
105
+ }
106
+
107
+ /**
108
+ * Options to construct a `batch` function
109
+ */
110
+ export type BatchConstructor<TFn extends (...args: any[]) => any> = {
111
+ /** Function to be wrapped */
112
+ fn: TFn;
113
+ /** Function to batch the inputs across multiple calls */
114
+ batch: (args: Parameters<TFn>[]) => Parameters<TFn>;
115
+
116
+ /**
117
+ * If provided, unbatch the output according to the inputs,
118
+ * so each call receives its own output.
119
+ *
120
+ * By default, each input will receive the same output from the batched call
121
+ */
122
+ unbatch?: (
123
+ inputs: Parameters<TFn>[],
124
+ output: AwaitRet<TFn>,
125
+ ) => AwaitRet<TFn>[];
126
+
127
+ /**
128
+ * Interval between each batched call
129
+ */
130
+ interval: number;
131
+
132
+ /** See `debounce` for more information */
133
+ disregardExecutionTime?: boolean;
134
+ };
135
+
136
+ class BatchImpl<TFn extends (...args: any[]) => any> {
137
+ private idle: boolean;
138
+ private scheduled: {
139
+ input: Parameters<TFn>;
140
+ promise: Promise<AwaitRet<TFn>>;
141
+ resolve: (value: AwaitRet<TFn>) => void;
142
+ reject: (error: unknown) => void;
143
+ }[];
144
+
145
+ constructor(
146
+ private fn: TFn,
147
+ private batch: (inputs: Parameters<TFn>[]) => Parameters<TFn>,
148
+ private unbatch:
149
+ | ((
150
+ input: Parameters<TFn>[],
151
+ output: AwaitRet<TFn>,
152
+ ) => AwaitRet<TFn>[])
153
+ | undefined,
154
+ private interval: number,
155
+ private disregardExecutionTime: boolean,
156
+ ) {
157
+ this.idle = true;
158
+ this.scheduled = [];
159
+ }
160
+
161
+ public invoke(...args: Parameters<TFn>): Promise<AwaitRet<TFn>> {
162
+ if (this.idle) {
163
+ this.idle = false;
164
+ return this.execute(...args);
165
+ }
166
+ const { promise, reject, resolve } = makePromise<AwaitRet<TFn>>();
167
+ this.scheduled.push({
168
+ input: args,
169
+ promise,
170
+ resolve,
171
+ reject,
172
+ });
173
+
174
+ return promise;
175
+ }
176
+
177
+ private async scheduleNext() {
178
+ const next = this.scheduled;
179
+ if (next.length) {
180
+ this.scheduled = [];
181
+ const batch = this.batch;
182
+ const inputs = next.map(({ input }) => input);
183
+ const input = next.length > 1 ? batch(inputs) : inputs[0];
184
+ try {
185
+ const output = await this.execute(...input);
186
+ const unbatch = this.unbatch;
187
+ if (unbatch && inputs.length > 1) {
188
+ const outputs = unbatch(inputs, output);
189
+ for (let i = 0; i < next.length; i++) {
190
+ const { resolve } = next[i];
191
+ resolve(outputs[i]);
192
+ }
193
+ } else {
194
+ for (let i = 0; i < next.length; i++) {
195
+ const { resolve } = next[i];
196
+ resolve(output);
197
+ }
198
+ }
199
+ } catch (e) {
200
+ for (let i = 0; i < next.length; i++) {
201
+ const { reject } = next[i];
202
+ reject(e);
203
+ }
204
+ }
205
+ return;
206
+ }
207
+ this.idle = true;
208
+ }
209
+
210
+ private async execute(...args: Parameters<TFn>): Promise<AwaitRet<TFn>> {
211
+ const fn = this.fn;
212
+ let done = this.disregardExecutionTime;
213
+ setTimeout(() => {
214
+ if (done) {
215
+ this.scheduleNext();
216
+ } else {
217
+ done = true;
218
+ }
219
+ }, this.interval);
220
+ try {
221
+ return await fn(...args);
222
+ } finally {
223
+ if (!this.disregardExecutionTime) {
224
+ if (done) {
225
+ // interval already passed, we need to call it
226
+ this.scheduleNext();
227
+ } else {
228
+ done = true;
229
+ }
230
+ }
231
+ }
232
+ }
233
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Create a light weight storage wrapper around a value
3
+ * that can be subscribed to for changes
4
+ */
5
+ export function cell<T>({ initial }: CellConstructor<T>): Cell<T> {
6
+ return new CellImpl(initial);
7
+ }
8
+
9
+ export type CellConstructor<T> = {
10
+ /** Initial value */
11
+ initial: T;
12
+ };
13
+
14
+ export type Cell<T> = {
15
+ get(): T;
16
+ set(value: T): void;
17
+ subscribe(
18
+ callback: (value: T) => void,
19
+ notifyImmediately?: boolean,
20
+ ): () => void;
21
+ };
22
+
23
+ class CellImpl<T> implements Cell<T> {
24
+ private subscribers: ((value: T) => void)[] = [];
25
+
26
+ constructor(private value: T) {}
27
+
28
+ public get(): T {
29
+ return this.value;
30
+ }
31
+
32
+ public set(value: T): void {
33
+ if (this.value === value) {
34
+ return;
35
+ }
36
+ this.value = value;
37
+ const len = this.subscribers.length;
38
+ for (let i = 0; i < len; i++) {
39
+ const callback = this.subscribers[i];
40
+ callback(value);
41
+ }
42
+ }
43
+
44
+ public subscribe(
45
+ callback: (value: T) => void,
46
+ notifyImmediately?: boolean,
47
+ ): () => void {
48
+ this.subscribers.push(callback);
49
+ const unsubscribe = () => {
50
+ const index = this.subscribers.indexOf(callback);
51
+ if (index >= 0) {
52
+ this.subscribers.splice(index, 1);
53
+ }
54
+ };
55
+ if (notifyImmediately) {
56
+ callback(this.value);
57
+ }
58
+ return unsubscribe;
59
+ }
60
+ }