@pistonite/pure 0.0.12

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.
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Client side log util
3
+ *
4
+ * @module
5
+ */
6
+
7
+ import Denque from "denque";
8
+ import { errstr } from "../result/index.ts";
9
+
10
+ const LIMIT = 500;
11
+
12
+ /** Global log queue */
13
+ const LogQueue = new Denque<string>();
14
+ function pushLog(msg: string) {
15
+ if (LogQueue.length > LIMIT) {
16
+ LogQueue.shift();
17
+ }
18
+ LogQueue.push(`[${new Date().toISOString()}]${msg}`);
19
+ }
20
+
21
+ /** Get the current log */
22
+ export function getLogLines(): string[] {
23
+ return LogQueue.toArray();
24
+ }
25
+
26
+ /** A general-purpose client side logger */
27
+ export class Logger {
28
+ /** The prefix of the logger */
29
+ private prefix: string;
30
+
31
+ constructor(prefix: string) {
32
+ this.prefix = prefix;
33
+ }
34
+
35
+ /** Log an info message */
36
+ public info(msg: string) {
37
+ const msgWithPrefix = `[${this.prefix}] ${msg}`;
38
+ self.console.info(msgWithPrefix);
39
+ pushLog(msgWithPrefix);
40
+ }
41
+
42
+ /** Log a warning message */
43
+ public warn(msg: string) {
44
+ const msgWithPrefix = `[${this.prefix}] ${msg}`;
45
+ self.console.warn(msgWithPrefix);
46
+ pushLog(msgWithPrefix);
47
+ }
48
+
49
+ /** Log an error message */
50
+ public error(msg: unknown) {
51
+ const msgWithPrefix = `[${this.prefix}] ${errstr(msg)}`;
52
+ self.console.error(msgWithPrefix);
53
+ self.console.error(msg);
54
+ pushLog(msgWithPrefix);
55
+ }
56
+ }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Dark mode wrappers
3
+ *
4
+ * ## Detect user preference
5
+ * User preference is detected with `matchMedia` API, if available.
6
+ * ```typescript
7
+ * import { prefersDarkMode } from "@pistonite/pure/dark";
8
+ *
9
+ * console.log(prefersDarkMode());
10
+ * ```
11
+ *
12
+ * ## Global dark mode state
13
+ * `initDark` initializes the dark mode state.
14
+ * ```typescript
15
+ * import { initDark, isDark, setDark, addDarkSubscriber } from "@pistonite/pure/dark";
16
+ *
17
+ * initDark();
18
+ * console.log(isDark());
19
+ *
20
+ * addDarkSubscriber((dark) => { console.log("Dark mode changed: ", dark); });
21
+ * setDark(true); // will trigger the subscriber
22
+ * ```
23
+ *
24
+ * ## Use with React
25
+ * A React hook is provided in the [`pure-react`](https://jsr.io/@pistonite/pure-react/doc/pref) package
26
+ * to get the dark mode state from React components.
27
+ *
28
+ * Use `setDark` to change the dark mode state from React compoenents like you would from anywhere else.
29
+ *
30
+ * ## Persisting to localStorage
31
+ * You can persist the dark mode preference to by passing `persist: true` to `initDark`.
32
+ * This will make `initDark` also load the preference from localStorage.
33
+ * ```typescript
34
+ * import { initDark } from "@pistonite/pure/dark";
35
+ *
36
+ * initDark({ persist: true });
37
+ * ```
38
+ *
39
+ * ## Setting `color-scheme` CSS property
40
+ * The `color-scheme` property handles dark mode for native components like buttons
41
+ * and scrollbars. By default, `initDark` will handle setting this property for the `:root` selector.
42
+ * You can override this by passing a `selector` option.
43
+ * ```typescript
44
+ * import { initDark } from "@pistonite/pure/dark";
45
+ *
46
+ * // will set `.my-app { color-scheme: dark }`
47
+ * initDark({ selector: ".my-app" });
48
+ * ```
49
+ *
50
+ * @module
51
+ */
52
+
53
+ import { injectStyle } from "./injectStyle.ts";
54
+
55
+ const KEY = "Pure.Dark";
56
+
57
+ let dark = false;
58
+ const subscribers: ((dark: boolean) => void)[] = [];
59
+
60
+ /**
61
+ * Returns if dark mode is prefered in the browser environment
62
+ *
63
+ * If `window.matchMedia` is not available, it will return `false`
64
+ */
65
+ export const prefersDarkMode = (): boolean => {
66
+ return !!globalThis.matchMedia?.("(prefers-color-scheme: dark)").matches;
67
+ };
68
+
69
+ /** Value for the `color-scheme` CSS property */
70
+ export type ColorScheme = "light" | "dark";
71
+ /** Option for initializing dark mode */
72
+ export type DarkOptions = {
73
+ /**
74
+ * Initial value for dark mode
75
+ *
76
+ * If not set, it will default to calling `prefersDarkMode()`.
77
+ *
78
+ * If `persist` is `true`, it will also check the value from localStorage
79
+ */
80
+ initial?: boolean;
81
+ /** Persist the dark mode preference to localStorage */
82
+ persist?: boolean;
83
+ /**
84
+ * The selector to set `color-scheme` property
85
+ *
86
+ * Defaults to `:root`. If set to empty string, CSS will not be updated
87
+ */
88
+ selector?: string;
89
+ };
90
+
91
+ /**
92
+ * Initializes dark mode
93
+ *
94
+ * @param options Options for initializing dark mode
95
+ */
96
+ export const initDark = (options: DarkOptions = {}): void => {
97
+ let _dark = options.initial || prefersDarkMode();
98
+
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
+ const selector = options.selector ?? ":root";
114
+ if (selector) {
115
+ // notify immediately to update the style initially
116
+ addDarkSubscriber((dark: boolean) => {
117
+ updateStyle(dark, selector);
118
+ }, true /* notify */);
119
+ }
120
+ };
121
+
122
+ /**
123
+ * Clears the persisted dark mode preference
124
+ *
125
+ * If you are doing this, you should probably call `setDark`
126
+ * with `prefersDarkMode()` or some initial value immediately before this,
127
+ * so the current dark mode is set to user's preferred mode.
128
+ *
129
+ * Note if `persist` is `true` when initializing,
130
+ * subsequence `setDark` calls will still persist the value.
131
+ */
132
+ export const clearPersistedDarkPerference = (): void => {
133
+ localStorage.removeItem(KEY);
134
+ };
135
+
136
+ /**
137
+ * Gets the current value of dark mode
138
+ */
139
+ export const isDark = (): boolean => dark;
140
+
141
+ /**
142
+ * Set the value of dark mode
143
+ */
144
+ 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
+ }
153
+ };
154
+ /**
155
+ * Add a subscriber to dark mode changes
156
+ *
157
+ * If `notifyImmediately` is `true`, the subscriber will be called immediately with the current value
158
+ */
159
+ export const addDarkSubscriber = (
160
+ subscriber: (dark: boolean) => void,
161
+ 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
+ }
179
+ };
180
+
181
+ const updateStyle = (dark: boolean, selector: string) => {
182
+ const text = `${selector} { color-scheme: ${dark ? "dark" : "light"}; }`;
183
+ injectStyle(KEY, text);
184
+ };
@@ -0,0 +1,12 @@
1
+ /**
2
+ * # pure/pref
3
+ * Preference utilities (things like locale and theme).
4
+ *
5
+ * These deal with raw CSS and DOM API, and is probably not
6
+ * very useful outside of browser environment.
7
+ *
8
+ * @module
9
+ */
10
+ export * from "./dark.ts";
11
+ export * from "./injectStyle.ts";
12
+ export * from "./locale.ts";
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Inject a css string into a style tag identified by the id
3
+ *
4
+ * Will remove the old style tag(s) if exist
5
+ */
6
+ export function injectStyle(id: string, style: string) {
7
+ const styleTags = document.querySelectorAll(`style[data-inject="${id}"`);
8
+ if (styleTags.length !== 1) {
9
+ const styleTag = document.createElement("style");
10
+ styleTag.setAttribute("data-inject", id);
11
+ styleTag.innerText = style;
12
+ document.head.appendChild(styleTag);
13
+ setTimeout(() => {
14
+ styleTags.forEach((tag) => tag.remove());
15
+ }, 0);
16
+ } else {
17
+ const e = styleTags[0] as HTMLStyleElement;
18
+ if (e.innerText !== style) {
19
+ e.innerText = style;
20
+ }
21
+ }
22
+ }
@@ -0,0 +1,341 @@
1
+ /**
2
+ * Locale utilities and integration with i18next
3
+ *
4
+ * ## Initialization
5
+ * `initLocale` must be called before using the other functions.
6
+ *
7
+ * ```typescript
8
+ * import { initLocale } from "@pistonite/pure/pref";
9
+ *
10
+ * initLocale({
11
+ * // required
12
+ * supported: ["en", "zh-CN", "zh-TW"] as const,
13
+ * default: "en",
14
+ *
15
+ * // optional
16
+ * persist: true, // save to localStorage
17
+ * initial: "en-US", // initial value, instead of detecting
18
+ * });
19
+ * ```
20
+ *
21
+ * ## Connecting with i18next
22
+ * The typical usage for this component is to use i18next for localization.
23
+ * This module provides 2 plugins:
24
+ * - `detectLocale`:
25
+ * - Provide the current language to i18next (as a language detector)
26
+ * - Update the global locale state whenever `i18next.changeLanguage` is called
27
+ * - `connectI18next`:
28
+ * - Call `i18next.changeLanguage` whenever `setLocale` is called
29
+ *
30
+ * You might only need one of these plugins, depending on your use case.
31
+ * For example, if you will never call `setLocale` manually, then you don't need `connectI18next`.
32
+ *
33
+ * ```typescript
34
+ * import i18next from "i18next";
35
+ * import { initLocale, detectLocale, connectI18next } from "@pistonite/pure/pref";
36
+ *
37
+ * // initialize locale
38
+ * initLocale({ supported: ["en", "es"], default: "en", persist: true });
39
+ *
40
+ * // connect with i18next
41
+ * i18next.use(detectLocale).use(connectI18next).init({
42
+ * // ...other options not shown
43
+ * });
44
+ * ```
45
+ *
46
+ * ## Use with React
47
+ * A React hook is provided in the [`pure-react`](https://jsr.io/@pistonite/pure-react/doc/pref) package
48
+ * to get the current locale from React components.
49
+ *
50
+ * Changing the locale from React components is the same as from outside React,
51
+ * with `setLocale` or `i18next.changeLanguage`, depending on your setup.
52
+ *
53
+ * @module
54
+ */
55
+
56
+ const KEY = "Pure.Locale";
57
+
58
+ let supportedLocales: readonly string[] = [];
59
+ let locale: string = "";
60
+ let defaultLocale: string = "";
61
+ const subscribers: ((locale: string) => void)[] = [];
62
+
63
+ /**
64
+ * Use browser API to guess user's preferred locale
65
+ */
66
+ export const getPreferredLocale = (): string => {
67
+ if (globalThis.Intl) {
68
+ try {
69
+ return globalThis.Intl.NumberFormat().resolvedOptions().locale;
70
+ } catch {
71
+ // ignore
72
+ }
73
+ }
74
+ if (globalThis.navigator?.languages) {
75
+ return globalThis.navigator.languages[0];
76
+ }
77
+ return "";
78
+ };
79
+
80
+ export type LocaleOptions<TLocale extends string> = {
81
+ /**
82
+ * List of supported locale or languages.
83
+ * These can be full locale strings like "en-US" or just languages like "en"
84
+ */
85
+ supported: readonly TLocale[];
86
+ /**
87
+ * The default locale if the user's preferred locale is not supported.
88
+ * This must be one of the items in `supported`.
89
+ */
90
+ default: TLocale;
91
+ /**
92
+ * Initial value for locale
93
+ *
94
+ * If not set, it will default to calling `getPreferredLocale()`,
95
+ * which is based on the browser's language settings.
96
+ *
97
+ * If `persist` is `true`, it will also check the value from localStorage
98
+ *
99
+ * If the initial value is not supported, it will default to the default locale
100
+ */
101
+ initial?: TLocale;
102
+
103
+ /**
104
+ * Persist the locale preference to localStorage
105
+ */
106
+ persist?: boolean;
107
+ };
108
+
109
+ /** Initialize locale global state */
110
+ export const initLocale = <TLocale extends string>(
111
+ options: LocaleOptions<TLocale>,
112
+ ): void => {
113
+ let _locale = "";
114
+ supportedLocales = options.supported;
115
+ if (options.initial) {
116
+ _locale = options.initial;
117
+ } else {
118
+ _locale =
119
+ convertToSupportedLocale(getPreferredLocale()) || options.default;
120
+ }
121
+ defaultLocale = options.default;
122
+ 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
+ });
133
+ } else {
134
+ localStorage.removeItem(KEY);
135
+ }
136
+
137
+ setLocale(_locale);
138
+ };
139
+
140
+ /**
141
+ * Clear the locale preference previously presisted to localStorage
142
+ *
143
+ * If you are doing this, you should probably call `setLocale`
144
+ * or `i18next.changeLanguage` (depending on your setup) immediately
145
+ * before this with `convertToSupportedLocaleOrDefault(getPreferredLocale())`
146
+ * so the current locale is set to user's preferred locale.
147
+ *
148
+ * Note if `persist` is `true` when initializing,
149
+ * subsequence `setLocale` calls will still persist the value.
150
+ */
151
+ export const clearPersistedLocalePreference = (): void => {
152
+ localStorage.removeItem(KEY);
153
+ };
154
+
155
+ /** Get the current selected locale */
156
+ export const getLocale = (): string => {
157
+ return locale;
158
+ };
159
+
160
+ /** Get the default locale when initialized */
161
+ export const getDefaultLocale = (): string => {
162
+ return defaultLocale;
163
+ };
164
+
165
+ /**
166
+ * Set the selected locale
167
+ *
168
+ * Returns `false` if the locale is not supported
169
+ */
170
+ export const setLocale = (newLocale: string): boolean => {
171
+ const supported = convertToSupportedLocale(newLocale);
172
+ if (!supported) {
173
+ return false;
174
+ }
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
+ }
183
+ return true;
184
+ };
185
+
186
+ /**
187
+ * Convert a locale/language to a supported locale/language
188
+ *
189
+ * Returns `undefined` if no supported locale is found
190
+ *
191
+ * # Example
192
+ * It will first try to find an exact match for a locale (not language).
193
+ * If not found, it will try:
194
+ * - the first supported locale with a matching language
195
+ * - the first supported language
196
+ * ```typescript
197
+ * import { convertToSupportedLocale } from "@pistonite/pure/pref";
198
+ *
199
+ * // suppose supported locales are ["en", "zh", "zh-CN"]
200
+ * console.log(convertToSupportedLocale("en")); // "en"
201
+ * console.log(convertToSupportedLocale("en-US")); // "en"
202
+ * console.log(convertToSupportedLocale("zh")); // "zh-CN"
203
+ * console.log(convertToSupportedLocale("zh-CN")); // "zh-CN"
204
+ * console.log(convertToSupportedLocale("zh-TW")); // "zh"
205
+ * console.log(convertToSupportedLocale("es")); // undefined
206
+ * ```
207
+ */
208
+ export const convertToSupportedLocale = (
209
+ newLocale: string,
210
+ ): string | undefined => {
211
+ if (supportedLocales.includes(newLocale)) {
212
+ return newLocale;
213
+ }
214
+ const language = newLocale.split("-", 2)[0];
215
+ const len = supportedLocales.length;
216
+ for (let i = 0; i < len; i++) {
217
+ if (supportedLocales[i].startsWith(language)) {
218
+ return supportedLocales[i];
219
+ }
220
+ }
221
+ return undefined;
222
+ };
223
+
224
+ /**
225
+ * Convert a locale/language to a supported locale/language,
226
+ * or return the default locale if not found.
227
+ *
228
+ * This is a thin wrapper for `convertToSupportedLocale`.
229
+ * See that function for more details.
230
+ */
231
+ export const convertToSupportedLocaleOrDefault = (
232
+ newLocale: string,
233
+ ): string => {
234
+ return convertToSupportedLocale(newLocale) || defaultLocale;
235
+ };
236
+
237
+ /**
238
+ * Add a subscriber to be notified when the locale changes
239
+ *
240
+ * If `notifyImmediately` is `true`, the subscriber will be called immediately with the current locale
241
+ */
242
+ export const addLocaleSubscriber = (
243
+ fn: (locale: string) => void,
244
+ 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
+ }
260
+ };
261
+
262
+ /**
263
+ * Language detector plugin for i18next
264
+ *
265
+ * **Must call `initLocale` before initializaing i18next**
266
+ *
267
+ * This also sets the global locale state whenever `i18next.changeLanguage` is called.
268
+ *
269
+ * # Example
270
+ * ```typescript
271
+ * import i18next from "i18next";
272
+ * import { initLocale, detectLocale } from "@pistonite/pure/pref";
273
+ *
274
+ * initLocale({ supported: ["en", "es"], default: "en", persist: true });
275
+ *
276
+ * i18next.use(detectLocale).init({
277
+ * // don't need to specify `lng` here
278
+ *
279
+ * // ...other options not shown
280
+ * });
281
+ * ```
282
+ *
283
+ */
284
+ export const detectLocale = {
285
+ type: "languageDetector" as const,
286
+ detect: () => locale,
287
+ cacheUserLanguage: (lng: string): void => {
288
+ setLocale(lng);
289
+ },
290
+ };
291
+
292
+ /**
293
+ * Bind the locale state to i18next, so whenever `setLocale`
294
+ * is called, it will also call `i18next.changeLanguage`.
295
+ *
296
+ * # Example
297
+ * ```typescript
298
+ * import i18next from "i18next";
299
+ * import { connectI18next, initLocale } from "@pistonite/pure/pref";
300
+ *
301
+ * initLocale({ supported: ["en", "es"], default: "en", persist: true });
302
+ * i18next.use(connectI18next).init({
303
+ * // ...options
304
+ * });
305
+ *
306
+ */
307
+ export const connectI18next = {
308
+ type: "3rdParty" as const,
309
+ init: (i18next: any): void => {
310
+ addLocaleSubscriber((locale) => {
311
+ if (i18next.language !== locale) {
312
+ i18next.changeLanguage(locale);
313
+ }
314
+ }, true);
315
+ },
316
+ };
317
+
318
+ const localizedLanguageNames = new Map();
319
+
320
+ /**
321
+ * Get the localized name of a language using `Intl.DisplayNames`.
322
+ *
323
+ * The results are interanlly cached, so you don't need to cache this yourself.
324
+ */
325
+ export const getLocalizedLanguageName = (language: string): string => {
326
+ if (language === "zh" || language === "zh-CN") {
327
+ return "\u7b80\u4f53\u4e2d\u6587";
328
+ }
329
+ if (language === "zh-TW") {
330
+ return "\u7e41\u9ad4\u4e2d\u6587";
331
+ }
332
+ if (localizedLanguageNames.has(language)) {
333
+ return localizedLanguageNames.get(language);
334
+ }
335
+ const languageWithoutLocale = language.split("-")[0];
336
+ const localized = new Intl.DisplayNames([language], {
337
+ type: "language",
338
+ }).of(languageWithoutLocale);
339
+ localizedLanguageNames.set(language, localized);
340
+ return localized || language;
341
+ };