@arraypress/extension-kit 0.0.0-stage → 1.1.0
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/LICENSE +21 -0
- package/README.md +217 -2
- package/bin/extension-kit.js +35 -0
- package/package.json +67 -4
- package/src/about/links.d.ts +51 -0
- package/src/about/links.js +48 -0
- package/src/build/duplication.d.ts +116 -0
- package/src/build/duplication.js +119 -0
- package/src/build/index.d.ts +11 -0
- package/src/build/index.js +12 -0
- package/src/build/release-checks.d.ts +36 -0
- package/src/build/release-checks.js +61 -0
- package/src/build/toolbar-icons.d.ts +96 -0
- package/src/build/toolbar-icons.js +101 -0
- package/src/build/vendor.d.ts +26 -0
- package/src/build/vendor.js +43 -0
- package/src/build/zip.d.ts +31 -0
- package/src/build/zip.js +47 -0
- package/src/commands/shortcuts.d.ts +39 -0
- package/src/commands/shortcuts.js +31 -0
- package/src/dom/i18n.d.ts +39 -0
- package/src/dom/i18n.js +49 -0
- package/src/dom/page.d.ts +59 -0
- package/src/dom/page.js +93 -0
- package/src/index.d.ts +21 -0
- package/src/index.js +24 -0
- package/src/settings/saver.d.ts +76 -0
- package/src/settings/saver.js +98 -0
- package/src/settings/storage.d.ts +91 -0
- package/src/settings/storage.js +76 -0
- package/src/settings/store.d.ts +138 -0
- package/src/settings/store.js +173 -0
- package/src/styles/chrome-ui.css +1094 -0
- package/src/testing/e2e.d.ts +222 -0
- package/src/testing/e2e.js +327 -0
- package/src/testing/fake-storage.d.ts +71 -0
- package/src/testing/fake-storage.js +145 -0
- package/src/testing/index.d.ts +29 -0
- package/src/testing/index.js +24 -0
- package/src/testing/package.d.ts +39 -0
- package/src/testing/package.js +96 -0
- package/src/testing/server.d.ts +47 -0
- package/src/testing/server.js +90 -0
- package/src/toolbar/offscreen.d.ts +30 -0
- package/src/toolbar/offscreen.js +37 -0
- package/src/toolbar/scheme.d.ts +81 -0
- package/src/toolbar/scheme.js +87 -0
- package/src/toolbar/theme.d.ts +187 -0
- package/src/toolbar/theme.js +176 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Localising extension pages from `_locales/<lang>/messages.json`. Markup
|
|
3
|
+
* opts in with `data-i18n="key"` (sets the text) or
|
|
4
|
+
* `data-i18n-<attribute>="key"` (sets that attribute, e.g.
|
|
5
|
+
* `data-i18n-placeholder`, `data-i18n-aria-label`).
|
|
6
|
+
*
|
|
7
|
+
* @module dom/i18n
|
|
8
|
+
*/
|
|
9
|
+
export type I18nApi = {
|
|
10
|
+
getMessage: (key: string, substitutions?: string | string[]) => string;
|
|
11
|
+
getUILanguage: () => string;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* `chrome.i18n`, as far as pages need it.
|
|
15
|
+
*
|
|
16
|
+
* @typedef {object} I18nApi
|
|
17
|
+
* @property {(key: string, substitutions?: string | string[]) => string} getMessage
|
|
18
|
+
* @property {() => string} getUILanguage
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* A message lookup that never comes back blank: a missing key shows as
|
|
22
|
+
* itself, so a typo is visible on screen instead of an empty label.
|
|
23
|
+
*
|
|
24
|
+
* @param {Pick<I18nApi, 'getMessage'>} i18n - `chrome.i18n`.
|
|
25
|
+
* @returns {(key: string, substitutions?: string | string[]) => string} Looks a key up.
|
|
26
|
+
* @example
|
|
27
|
+
* const t = translator(chrome.i18n);
|
|
28
|
+
* t('optionsVersion', ['1.0.0']); // 'Version 1.0.0'
|
|
29
|
+
*/
|
|
30
|
+
export declare function translator(i18n: Pick<I18nApi, 'getMessage'>): (key: string, substitutions?: string | string[]) => string;
|
|
31
|
+
/**
|
|
32
|
+
* Fill every `data-i18n*` attribute under `root`, and, for a whole document,
|
|
33
|
+
* set its `lang` to Chrome's UI language.
|
|
34
|
+
*
|
|
35
|
+
* @param {ParentNode} root - A document or an element.
|
|
36
|
+
* @param {I18nApi} i18n - `chrome.i18n`.
|
|
37
|
+
* @returns {void}
|
|
38
|
+
*/
|
|
39
|
+
export declare function localize(root: ParentNode, i18n: I18nApi): void;
|
package/src/dom/i18n.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Localising extension pages from `_locales/<lang>/messages.json`. Markup
|
|
3
|
+
* opts in with `data-i18n="key"` (sets the text) or
|
|
4
|
+
* `data-i18n-<attribute>="key"` (sets that attribute, e.g.
|
|
5
|
+
* `data-i18n-placeholder`, `data-i18n-aria-label`).
|
|
6
|
+
*
|
|
7
|
+
* @module dom/i18n
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `chrome.i18n`, as far as pages need it.
|
|
12
|
+
*
|
|
13
|
+
* @typedef {object} I18nApi
|
|
14
|
+
* @property {(key: string, substitutions?: string | string[]) => string} getMessage
|
|
15
|
+
* @property {() => string} getUILanguage
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A message lookup that never comes back blank: a missing key shows as
|
|
20
|
+
* itself, so a typo is visible on screen instead of an empty label.
|
|
21
|
+
*
|
|
22
|
+
* @param {Pick<I18nApi, 'getMessage'>} i18n - `chrome.i18n`.
|
|
23
|
+
* @returns {(key: string, substitutions?: string | string[]) => string} Looks a key up.
|
|
24
|
+
* @example
|
|
25
|
+
* const t = translator(chrome.i18n);
|
|
26
|
+
* t('optionsVersion', ['1.0.0']); // 'Version 1.0.0'
|
|
27
|
+
*/
|
|
28
|
+
export function translator(i18n) {
|
|
29
|
+
return (key, substitutions) => i18n.getMessage(key, substitutions) || key;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Fill every `data-i18n*` attribute under `root`, and, for a whole document,
|
|
34
|
+
* set its `lang` to Chrome's UI language.
|
|
35
|
+
*
|
|
36
|
+
* @param {ParentNode} root - A document or an element.
|
|
37
|
+
* @param {I18nApi} i18n - `chrome.i18n`.
|
|
38
|
+
* @returns {void}
|
|
39
|
+
*/
|
|
40
|
+
export function localize(root, i18n) {
|
|
41
|
+
const t = translator(i18n);
|
|
42
|
+
for (const element of root.querySelectorAll('*')) {
|
|
43
|
+
for (const { name, value } of [...element.attributes]) {
|
|
44
|
+
if (name === 'data-i18n') element.textContent = t(value);
|
|
45
|
+
else if (name.startsWith('data-i18n-')) element.setAttribute(name.slice('data-i18n-'.length), t(value));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if ('documentElement' in root) /** @type {Document} */ (root).documentElement.lang = i18n.getUILanguage();
|
|
49
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small DOM helpers for extension pages built on chrome-ui.css: finding an
|
|
3
|
+
* element with its real type, the markup of a value row, the About card's
|
|
4
|
+
* links, and the "Saved" indicator. Browser only.
|
|
5
|
+
*
|
|
6
|
+
* @module dom/page
|
|
7
|
+
*/
|
|
8
|
+
import type { AboutLinks } from '../about/links.js';
|
|
9
|
+
/** @import { AboutLinks } from '../about/links.js' */
|
|
10
|
+
/**
|
|
11
|
+
* The element with `id`, checked to be a `Type`, so the page's code gets the
|
|
12
|
+
* real type (an `HTMLInputElement` has `.checked`) and fails loudly when the
|
|
13
|
+
* markup and the script disagree.
|
|
14
|
+
*
|
|
15
|
+
* @template {typeof HTMLElement} C
|
|
16
|
+
* @param {string} id - The element's id.
|
|
17
|
+
* @param {C} [Type] - Its class. Default `HTMLElement`.
|
|
18
|
+
* @param {ParentNode & {getElementById(id: string): HTMLElement | null}} [root] - Where to look. Default `document`.
|
|
19
|
+
* @returns {InstanceType<C>} The element.
|
|
20
|
+
* @throws {Error} When there is no such element, or it isn't a `Type`.
|
|
21
|
+
* @example
|
|
22
|
+
* const days = byId('inactiveDays', HTMLInputElement);
|
|
23
|
+
* days.valueAsNumber; // typed
|
|
24
|
+
*/
|
|
25
|
+
export declare function byId<C extends typeof HTMLElement>(id: string, Type?: C, root?: ParentNode & {
|
|
26
|
+
getElementById(id: string): HTMLElement | null;
|
|
27
|
+
}): InstanceType<C>;
|
|
28
|
+
/**
|
|
29
|
+
* A row with a label and a value at its end, as chrome://settings shows a
|
|
30
|
+
* keyboard shortcut: `.row` > `.label` + `.row-value`.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} label - The row's text.
|
|
33
|
+
* @param {string} value - What sits at its end.
|
|
34
|
+
* @param {Document} [doc] - Where to make it. Default `document`.
|
|
35
|
+
* @returns {HTMLDivElement} The row.
|
|
36
|
+
*/
|
|
37
|
+
export declare function valueRow(label: string, value: string, doc?: Document): HTMLDivElement;
|
|
38
|
+
/**
|
|
39
|
+
* Point the About card's link rows (`#help`, `#report-issue`,
|
|
40
|
+
* `#privacy-policy`, all `<a>`) at their addresses.
|
|
41
|
+
*
|
|
42
|
+
* @param {AboutLinks} links - From `githubAboutLinks`.
|
|
43
|
+
* @param {Document} [doc] - The page. Default `document`.
|
|
44
|
+
* @returns {void}
|
|
45
|
+
* @throws {Error} When a link row is missing.
|
|
46
|
+
*/
|
|
47
|
+
export declare function setAboutLinks(links: AboutLinks, doc?: Document): void;
|
|
48
|
+
/**
|
|
49
|
+
* The "Saved" indicator: a `.save-status` element that shows a text, then
|
|
50
|
+
* fades out after `ms`. A new flash restarts the wait.
|
|
51
|
+
*
|
|
52
|
+
* @param {HTMLElement} element - The `.save-status` element.
|
|
53
|
+
* @param {number} ms - How long it stays.
|
|
54
|
+
* @returns {(text: string) => void} Shows `text`.
|
|
55
|
+
* @example
|
|
56
|
+
* const flash = statusFlasher(byId('status'), 1500);
|
|
57
|
+
* flash('Saved');
|
|
58
|
+
*/
|
|
59
|
+
export declare function statusFlasher(element: HTMLElement, ms: number): (text: string) => void;
|
package/src/dom/page.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small DOM helpers for extension pages built on chrome-ui.css: finding an
|
|
3
|
+
* element with its real type, the markup of a value row, the About card's
|
|
4
|
+
* links, and the "Saved" indicator. Browser only.
|
|
5
|
+
*
|
|
6
|
+
* @module dom/page
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** @import { AboutLinks } from '../about/links.js' */
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The element with `id`, checked to be a `Type`, so the page's code gets the
|
|
13
|
+
* real type (an `HTMLInputElement` has `.checked`) and fails loudly when the
|
|
14
|
+
* markup and the script disagree.
|
|
15
|
+
*
|
|
16
|
+
* @template {typeof HTMLElement} C
|
|
17
|
+
* @param {string} id - The element's id.
|
|
18
|
+
* @param {C} [Type] - Its class. Default `HTMLElement`.
|
|
19
|
+
* @param {ParentNode & {getElementById(id: string): HTMLElement | null}} [root] - Where to look. Default `document`.
|
|
20
|
+
* @returns {InstanceType<C>} The element.
|
|
21
|
+
* @throws {Error} When there is no such element, or it isn't a `Type`.
|
|
22
|
+
* @example
|
|
23
|
+
* const days = byId('inactiveDays', HTMLInputElement);
|
|
24
|
+
* days.valueAsNumber; // typed
|
|
25
|
+
*/
|
|
26
|
+
export function byId(id, Type = /** @type {C} */ (HTMLElement), root = document) {
|
|
27
|
+
/** @type {Element | null} */
|
|
28
|
+
const element = root.getElementById(id);
|
|
29
|
+
if (!element) throw new Error(`#${id} is missing`);
|
|
30
|
+
if (!(element instanceof Type)) throw new Error(`#${id} is a ${element.constructor.name}, not a ${Type.name}`);
|
|
31
|
+
return /** @type {InstanceType<C>} */ (element);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A row with a label and a value at its end, as chrome://settings shows a
|
|
36
|
+
* keyboard shortcut: `.row` > `.label` + `.row-value`.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} label - The row's text.
|
|
39
|
+
* @param {string} value - What sits at its end.
|
|
40
|
+
* @param {Document} [doc] - Where to make it. Default `document`.
|
|
41
|
+
* @returns {HTMLDivElement} The row.
|
|
42
|
+
*/
|
|
43
|
+
export function valueRow(label, value, doc = document) {
|
|
44
|
+
const row = doc.createElement('div');
|
|
45
|
+
row.className = 'row';
|
|
46
|
+
const text = doc.createElement('span');
|
|
47
|
+
text.className = 'label';
|
|
48
|
+
text.textContent = label;
|
|
49
|
+
const end = doc.createElement('span');
|
|
50
|
+
end.className = 'row-value';
|
|
51
|
+
end.textContent = value;
|
|
52
|
+
row.append(text, end);
|
|
53
|
+
return row;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Point the About card's link rows (`#help`, `#report-issue`,
|
|
58
|
+
* `#privacy-policy`, all `<a>`) at their addresses.
|
|
59
|
+
*
|
|
60
|
+
* @param {AboutLinks} links - From `githubAboutLinks`.
|
|
61
|
+
* @param {Document} [doc] - The page. Default `document`.
|
|
62
|
+
* @returns {void}
|
|
63
|
+
* @throws {Error} When a link row is missing.
|
|
64
|
+
*/
|
|
65
|
+
export function setAboutLinks(links, doc = document) {
|
|
66
|
+
// The page's own class, so this works in any window (and in jsdom).
|
|
67
|
+
const { HTMLAnchorElement: Anchor } = /** @type {Window & typeof globalThis} */ (doc.defaultView);
|
|
68
|
+
byId('help', Anchor, doc).href = links.help;
|
|
69
|
+
byId('report-issue', Anchor, doc).href = links.reportIssue;
|
|
70
|
+
byId('privacy-policy', Anchor, doc).href = links.privacyPolicy;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The "Saved" indicator: a `.save-status` element that shows a text, then
|
|
75
|
+
* fades out after `ms`. A new flash restarts the wait.
|
|
76
|
+
*
|
|
77
|
+
* @param {HTMLElement} element - The `.save-status` element.
|
|
78
|
+
* @param {number} ms - How long it stays.
|
|
79
|
+
* @returns {(text: string) => void} Shows `text`.
|
|
80
|
+
* @example
|
|
81
|
+
* const flash = statusFlasher(byId('status'), 1500);
|
|
82
|
+
* flash('Saved');
|
|
83
|
+
*/
|
|
84
|
+
export function statusFlasher(element, ms) {
|
|
85
|
+
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
86
|
+
let timer;
|
|
87
|
+
return (text) => {
|
|
88
|
+
element.textContent = text;
|
|
89
|
+
element.classList.add('show');
|
|
90
|
+
clearTimeout(timer);
|
|
91
|
+
timer = setTimeout(() => element.classList.remove('show'), ms);
|
|
92
|
+
};
|
|
93
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The kit's browser half: what an extension's pages, offscreen document and
|
|
3
|
+
* service worker load. Every function takes the `chrome` APIs it uses as
|
|
4
|
+
* arguments, so tests run the real logic against fakes.
|
|
5
|
+
*
|
|
6
|
+
* Build-time helpers are in `@arraypress/extension-kit/build`, test helpers
|
|
7
|
+
* in `@arraypress/extension-kit/testing`, and the stylesheet is
|
|
8
|
+
* `@arraypress/extension-kit/chrome-ui.css`.
|
|
9
|
+
*
|
|
10
|
+
* @module extension-kit
|
|
11
|
+
*/
|
|
12
|
+
export { githubAboutLinks, isPlaceholder } from './about/links.js';
|
|
13
|
+
export { setShortcuts } from './commands/shortcuts.js';
|
|
14
|
+
export { localize, translator } from './dom/i18n.js';
|
|
15
|
+
export { byId, setAboutLinks, statusFlasher, valueRow } from './dom/page.js';
|
|
16
|
+
export { createSettingsSaver } from './settings/saver.js';
|
|
17
|
+
export { MAX_SYNC_ITEM_BYTES } from './settings/storage.js';
|
|
18
|
+
export { createSettingsStore } from './settings/store.js';
|
|
19
|
+
export { POLL_MS, watchSchemeOffscreen } from './toolbar/offscreen.js';
|
|
20
|
+
export { DARK_QUERY, TOOLBAR_DARK_KEY, TOOLBAR_SCHEME, reportColorScheme, schemeSender, watchColorScheme } from './toolbar/scheme.js';
|
|
21
|
+
export { OFFSCREEN_PATH, TOOLBAR_ICON_DIR, createToolbarTheme, startToolbarTheme, toolbarIcon, watchFromOffscreen, } from './toolbar/theme.js';
|
package/src/index.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The kit's browser half: what an extension's pages, offscreen document and
|
|
3
|
+
* service worker load. Every function takes the `chrome` APIs it uses as
|
|
4
|
+
* arguments, so tests run the real logic against fakes.
|
|
5
|
+
*
|
|
6
|
+
* Build-time helpers are in `@arraypress/extension-kit/build`, test helpers
|
|
7
|
+
* in `@arraypress/extension-kit/testing`, and the stylesheet is
|
|
8
|
+
* `@arraypress/extension-kit/chrome-ui.css`.
|
|
9
|
+
*
|
|
10
|
+
* @module extension-kit
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export { githubAboutLinks, isPlaceholder } from './about/links.js';
|
|
14
|
+
export { setShortcuts } from './commands/shortcuts.js';
|
|
15
|
+
export { localize, translator } from './dom/i18n.js';
|
|
16
|
+
export { byId, setAboutLinks, statusFlasher, valueRow } from './dom/page.js';
|
|
17
|
+
export { createSettingsSaver } from './settings/saver.js';
|
|
18
|
+
export { MAX_SYNC_ITEM_BYTES } from './settings/storage.js';
|
|
19
|
+
export { createSettingsStore } from './settings/store.js';
|
|
20
|
+
export { POLL_MS, watchSchemeOffscreen } from './toolbar/offscreen.js';
|
|
21
|
+
export { DARK_QUERY, TOOLBAR_DARK_KEY, TOOLBAR_SCHEME, reportColorScheme, schemeSender, watchColorScheme } from './toolbar/scheme.js';
|
|
22
|
+
export {
|
|
23
|
+
OFFSCREEN_PATH, TOOLBAR_ICON_DIR, createToolbarTheme, startToolbarTheme, toolbarIcon, watchFromOffscreen,
|
|
24
|
+
} from './toolbar/theme.js';
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Debounced saving for an options page. Chrome Sync allows 120 writes a
|
|
3
|
+
* minute, and a person flicking switches can beat that, so changes apply at
|
|
4
|
+
* once on the page but reach storage only once the clicks stop: a burst of
|
|
5
|
+
* toggles is one sync write. If sync refuses anyway, the store keeps the
|
|
6
|
+
* change on this computer and the saver tries again later.
|
|
7
|
+
*
|
|
8
|
+
* @module settings/saver
|
|
9
|
+
*/
|
|
10
|
+
import type { SettingsStore } from './store.js';
|
|
11
|
+
import type { SyncedStorage } from './storage.js';
|
|
12
|
+
export type SaverOptions<S extends Record<string, unknown>> = {
|
|
13
|
+
/**
|
|
14
|
+
* Quiet time before a write, ms. Default 1000.
|
|
15
|
+
*/
|
|
16
|
+
delay?: number;
|
|
17
|
+
/**
|
|
18
|
+
* Wait before retrying what sync refused, ms. Default 60,000.
|
|
19
|
+
*/
|
|
20
|
+
retryAfter?: number;
|
|
21
|
+
/**
|
|
22
|
+
* Called with every setting after each write.
|
|
23
|
+
*/
|
|
24
|
+
onSaved?: (settings: S) => void;
|
|
25
|
+
};
|
|
26
|
+
export type SettingsSaver<S extends Record<string, unknown>> = {
|
|
27
|
+
/**
|
|
28
|
+
* A copy of the changes not yet written, to lay over what storage holds.
|
|
29
|
+
*/
|
|
30
|
+
pending: Partial<S>;
|
|
31
|
+
/**
|
|
32
|
+
* Queue `changes`; the write starts once `delay` passes quietly.
|
|
33
|
+
*/
|
|
34
|
+
save: (changes: Partial<S>) => void;
|
|
35
|
+
/**
|
|
36
|
+
* Write what's queued now (call it as the page is hidden);
|
|
37
|
+
* resolves once every write so far has finished.
|
|
38
|
+
*/
|
|
39
|
+
flush: () => Promise<unknown>;
|
|
40
|
+
/**
|
|
41
|
+
* Cancel the timers (tests, or a page being torn down).
|
|
42
|
+
*/
|
|
43
|
+
stop: () => void;
|
|
44
|
+
};
|
|
45
|
+
/** @import { SettingsStore } from './store.js' */
|
|
46
|
+
/** @import { SyncedStorage } from './storage.js' */
|
|
47
|
+
/**
|
|
48
|
+
* @template {Record<string, unknown>} S
|
|
49
|
+
* @typedef {object} SaverOptions
|
|
50
|
+
* @property {number} [delay] Quiet time before a write, ms. Default 1000.
|
|
51
|
+
* @property {number} [retryAfter] Wait before retrying what sync refused, ms. Default 60,000.
|
|
52
|
+
* @property {(settings: S) => void} [onSaved] Called with every setting after each write.
|
|
53
|
+
*/
|
|
54
|
+
/**
|
|
55
|
+
* @template {Record<string, unknown>} S
|
|
56
|
+
* @typedef {object} SettingsSaver
|
|
57
|
+
* @property {Partial<S>} pending A copy of the changes not yet written, to lay over what storage holds.
|
|
58
|
+
* @property {(changes: Partial<S>) => void} save Queue `changes`; the write starts once `delay` passes quietly.
|
|
59
|
+
* @property {() => Promise<unknown>} flush Write what's queued now (call it as the page is hidden);
|
|
60
|
+
* resolves once every write so far has finished.
|
|
61
|
+
* @property {() => void} stop Cancel the timers (tests, or a page being torn down).
|
|
62
|
+
*/
|
|
63
|
+
/**
|
|
64
|
+
* Make a debounced saver over a settings store.
|
|
65
|
+
*
|
|
66
|
+
* @template {Record<string, unknown>} S
|
|
67
|
+
* @param {SettingsStore<S>} store - The extension's settings store.
|
|
68
|
+
* @param {SyncedStorage} storage - `chrome.storage`, or a fake.
|
|
69
|
+
* @param {SaverOptions<S>} [options] - Timing and the saved callback.
|
|
70
|
+
* @returns {SettingsSaver<S>} The saver.
|
|
71
|
+
* @example
|
|
72
|
+
* const saver = createSettingsSaver(store, chrome.storage, { onSaved: showSaved });
|
|
73
|
+
* toggle.addEventListener('change', () => saver.save({ compact: toggle.checked }));
|
|
74
|
+
* addEventListener('pagehide', () => saver.flush());
|
|
75
|
+
*/
|
|
76
|
+
export declare function createSettingsSaver<S extends Record<string, unknown>>(store: SettingsStore<S>, storage: SyncedStorage, { delay, retryAfter, onSaved }?: SaverOptions<S>): SettingsSaver<S>;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Debounced saving for an options page. Chrome Sync allows 120 writes a
|
|
3
|
+
* minute, and a person flicking switches can beat that, so changes apply at
|
|
4
|
+
* once on the page but reach storage only once the clicks stop: a burst of
|
|
5
|
+
* toggles is one sync write. If sync refuses anyway, the store keeps the
|
|
6
|
+
* change on this computer and the saver tries again later.
|
|
7
|
+
*
|
|
8
|
+
* @module settings/saver
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** @import { SettingsStore } from './store.js' */
|
|
12
|
+
/** @import { SyncedStorage } from './storage.js' */
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @template {Record<string, unknown>} S
|
|
16
|
+
* @typedef {object} SaverOptions
|
|
17
|
+
* @property {number} [delay] Quiet time before a write, ms. Default 1000.
|
|
18
|
+
* @property {number} [retryAfter] Wait before retrying what sync refused, ms. Default 60,000.
|
|
19
|
+
* @property {(settings: S) => void} [onSaved] Called with every setting after each write.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @template {Record<string, unknown>} S
|
|
24
|
+
* @typedef {object} SettingsSaver
|
|
25
|
+
* @property {Partial<S>} pending A copy of the changes not yet written, to lay over what storage holds.
|
|
26
|
+
* @property {(changes: Partial<S>) => void} save Queue `changes`; the write starts once `delay` passes quietly.
|
|
27
|
+
* @property {() => Promise<unknown>} flush Write what's queued now (call it as the page is hidden);
|
|
28
|
+
* resolves once every write so far has finished.
|
|
29
|
+
* @property {() => void} stop Cancel the timers (tests, or a page being torn down).
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Make a debounced saver over a settings store.
|
|
34
|
+
*
|
|
35
|
+
* @template {Record<string, unknown>} S
|
|
36
|
+
* @param {SettingsStore<S>} store - The extension's settings store.
|
|
37
|
+
* @param {SyncedStorage} storage - `chrome.storage`, or a fake.
|
|
38
|
+
* @param {SaverOptions<S>} [options] - Timing and the saved callback.
|
|
39
|
+
* @returns {SettingsSaver<S>} The saver.
|
|
40
|
+
* @example
|
|
41
|
+
* const saver = createSettingsSaver(store, chrome.storage, { onSaved: showSaved });
|
|
42
|
+
* toggle.addEventListener('change', () => saver.save({ compact: toggle.checked }));
|
|
43
|
+
* addEventListener('pagehide', () => saver.flush());
|
|
44
|
+
*/
|
|
45
|
+
export function createSettingsSaver(store, storage, { delay = 1000, retryAfter = 60_000, onSaved = () => {} } = {}) {
|
|
46
|
+
/** @type {Partial<S>} */
|
|
47
|
+
let pending = {};
|
|
48
|
+
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
49
|
+
let timer;
|
|
50
|
+
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
51
|
+
let retryTimer;
|
|
52
|
+
/** @type {Promise<unknown>} */
|
|
53
|
+
let writing = Promise.resolve();
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Track a write so `flush()` can be awaited; a failure is logged, never thrown.
|
|
57
|
+
*
|
|
58
|
+
* @param {Promise<void>} promise
|
|
59
|
+
* @returns {Promise<unknown>}
|
|
60
|
+
*/
|
|
61
|
+
const track = (promise) => {
|
|
62
|
+
writing = Promise.all([writing, promise.catch((error) => console.error('Saving settings:', error))]);
|
|
63
|
+
return writing;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
function retry() {
|
|
67
|
+
track(store.retryUnsynced(storage).then((synced) => {
|
|
68
|
+
if (!synced) retryTimer = setTimeout(retry, retryAfter);
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Writes start at once, not after earlier ones finish: Chrome applies them in order. */
|
|
73
|
+
function flush() {
|
|
74
|
+
clearTimeout(timer);
|
|
75
|
+
const changes = pending;
|
|
76
|
+
pending = {};
|
|
77
|
+
if (!Object.keys(changes).length) return writing;
|
|
78
|
+
return track(store.save(storage, changes).then(({ settings, synced }) => {
|
|
79
|
+
clearTimeout(retryTimer);
|
|
80
|
+
if (!synced) retryTimer = setTimeout(retry, retryAfter);
|
|
81
|
+
onSaved(settings);
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
get pending() { return { ...pending }; },
|
|
87
|
+
save(changes) {
|
|
88
|
+
pending = { ...pending, ...changes };
|
|
89
|
+
clearTimeout(timer);
|
|
90
|
+
timer = setTimeout(flush, delay);
|
|
91
|
+
},
|
|
92
|
+
flush,
|
|
93
|
+
stop() {
|
|
94
|
+
clearTimeout(timer);
|
|
95
|
+
clearTimeout(retryTimer);
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The slice of `chrome.storage` the settings store uses, as types, plus the
|
|
3
|
+
* small helpers every part of the store shares.
|
|
4
|
+
*
|
|
5
|
+
* The store never touches the `chrome` global: callers pass `chrome.storage`
|
|
6
|
+
* (or a fake in tests), so the logic runs and is tested unchanged in Node.
|
|
7
|
+
*
|
|
8
|
+
* @module settings/storage
|
|
9
|
+
*/
|
|
10
|
+
export type StorageArea = {
|
|
11
|
+
/**
|
|
12
|
+
* Read `keys` (all when null); missing keys are absent from the result.
|
|
13
|
+
*/
|
|
14
|
+
get: (keys?: string | string[] | null) => Promise<Record<string, any>>;
|
|
15
|
+
/**
|
|
16
|
+
* Write `items`.
|
|
17
|
+
*/
|
|
18
|
+
set: (items: Record<string, unknown>) => Promise<void>;
|
|
19
|
+
/**
|
|
20
|
+
* Remove `keys`.
|
|
21
|
+
*/
|
|
22
|
+
remove: (keys: string | string[]) => Promise<void>;
|
|
23
|
+
};
|
|
24
|
+
export type SyncedStorage = {
|
|
25
|
+
/**
|
|
26
|
+
* `chrome.storage.sync`: follows the user's Chrome Sync.
|
|
27
|
+
*/
|
|
28
|
+
sync: StorageArea;
|
|
29
|
+
/**
|
|
30
|
+
* `chrome.storage.local`: this computer only.
|
|
31
|
+
*/
|
|
32
|
+
local: StorageArea;
|
|
33
|
+
};
|
|
34
|
+
export type StorageChange = {
|
|
35
|
+
oldValue?: unknown;
|
|
36
|
+
newValue?: unknown;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* One `chrome.storage` area (`chrome.storage.sync`, `.local`, …), promise form.
|
|
40
|
+
*
|
|
41
|
+
* @typedef {object} StorageArea
|
|
42
|
+
* @property {(keys?: string | string[] | null) => Promise<Record<string, any>>} get
|
|
43
|
+
* Read `keys` (all when null); missing keys are absent from the result.
|
|
44
|
+
* @property {(items: Record<string, unknown>) => Promise<void>} set Write `items`.
|
|
45
|
+
* @property {(keys: string | string[]) => Promise<void>} remove Remove `keys`.
|
|
46
|
+
*/
|
|
47
|
+
/**
|
|
48
|
+
* `chrome.storage`, as far as settings need it: the synced area and this
|
|
49
|
+
* computer's own.
|
|
50
|
+
*
|
|
51
|
+
* @typedef {object} SyncedStorage
|
|
52
|
+
* @property {StorageArea} sync `chrome.storage.sync`: follows the user's Chrome Sync.
|
|
53
|
+
* @property {StorageArea} local `chrome.storage.local`: this computer only.
|
|
54
|
+
*/
|
|
55
|
+
/**
|
|
56
|
+
* One entry of a `chrome.storage.onChanged` event.
|
|
57
|
+
*
|
|
58
|
+
* @typedef {{oldValue?: unknown, newValue?: unknown}} StorageChange
|
|
59
|
+
*/
|
|
60
|
+
/**
|
|
61
|
+
* Chrome refuses a sync item over 8,192 bytes (key plus JSON value):
|
|
62
|
+
* `chrome.storage.sync.QUOTA_BYTES_PER_ITEM`.
|
|
63
|
+
*/
|
|
64
|
+
export declare const MAX_SYNC_ITEM_BYTES = 8192;
|
|
65
|
+
/**
|
|
66
|
+
* The entries of `object` whose keys are in `keys`, in `keys`' order.
|
|
67
|
+
*
|
|
68
|
+
* @template {Record<string, unknown>} T
|
|
69
|
+
* @param {T} object - Where to pick from.
|
|
70
|
+
* @param {readonly string[]} keys - Which keys to keep; absent ones are skipped.
|
|
71
|
+
* @returns {Partial<T>} A new object.
|
|
72
|
+
* @example
|
|
73
|
+
* pick({ a: 1, b: 2 }, ['b', 'c']); // { b: 2 }
|
|
74
|
+
*/
|
|
75
|
+
export declare function pick<T extends Record<string, unknown>>(object: T, keys: readonly string[]): Partial<T>;
|
|
76
|
+
/**
|
|
77
|
+
* How many bytes sync counts for one item: the key plus the value's JSON,
|
|
78
|
+
* UTF-8 encoded, as Chrome measures `QUOTA_BYTES_PER_ITEM`.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} key - The item's key.
|
|
81
|
+
* @param {unknown} value - The item's value.
|
|
82
|
+
* @returns {number} Bytes.
|
|
83
|
+
*/
|
|
84
|
+
export declare function syncItemBytes(key: string, value: unknown): number;
|
|
85
|
+
/**
|
|
86
|
+
* Whether an object has no own keys.
|
|
87
|
+
*
|
|
88
|
+
* @param {object} object - The object to look at.
|
|
89
|
+
* @returns {boolean} True for `{}`.
|
|
90
|
+
*/
|
|
91
|
+
export declare function isEmpty(object: object): boolean;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The slice of `chrome.storage` the settings store uses, as types, plus the
|
|
3
|
+
* small helpers every part of the store shares.
|
|
4
|
+
*
|
|
5
|
+
* The store never touches the `chrome` global: callers pass `chrome.storage`
|
|
6
|
+
* (or a fake in tests), so the logic runs and is tested unchanged in Node.
|
|
7
|
+
*
|
|
8
|
+
* @module settings/storage
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* One `chrome.storage` area (`chrome.storage.sync`, `.local`, …), promise form.
|
|
13
|
+
*
|
|
14
|
+
* @typedef {object} StorageArea
|
|
15
|
+
* @property {(keys?: string | string[] | null) => Promise<Record<string, any>>} get
|
|
16
|
+
* Read `keys` (all when null); missing keys are absent from the result.
|
|
17
|
+
* @property {(items: Record<string, unknown>) => Promise<void>} set Write `items`.
|
|
18
|
+
* @property {(keys: string | string[]) => Promise<void>} remove Remove `keys`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* `chrome.storage`, as far as settings need it: the synced area and this
|
|
23
|
+
* computer's own.
|
|
24
|
+
*
|
|
25
|
+
* @typedef {object} SyncedStorage
|
|
26
|
+
* @property {StorageArea} sync `chrome.storage.sync`: follows the user's Chrome Sync.
|
|
27
|
+
* @property {StorageArea} local `chrome.storage.local`: this computer only.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* One entry of a `chrome.storage.onChanged` event.
|
|
32
|
+
*
|
|
33
|
+
* @typedef {{oldValue?: unknown, newValue?: unknown}} StorageChange
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Chrome refuses a sync item over 8,192 bytes (key plus JSON value):
|
|
38
|
+
* `chrome.storage.sync.QUOTA_BYTES_PER_ITEM`.
|
|
39
|
+
*/
|
|
40
|
+
export const MAX_SYNC_ITEM_BYTES = 8192;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The entries of `object` whose keys are in `keys`, in `keys`' order.
|
|
44
|
+
*
|
|
45
|
+
* @template {Record<string, unknown>} T
|
|
46
|
+
* @param {T} object - Where to pick from.
|
|
47
|
+
* @param {readonly string[]} keys - Which keys to keep; absent ones are skipped.
|
|
48
|
+
* @returns {Partial<T>} A new object.
|
|
49
|
+
* @example
|
|
50
|
+
* pick({ a: 1, b: 2 }, ['b', 'c']); // { b: 2 }
|
|
51
|
+
*/
|
|
52
|
+
export function pick(object, keys) {
|
|
53
|
+
return /** @type {Partial<T>} */ (Object.fromEntries(keys.filter((key) => key in object).map((key) => [key, object[key]])));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* How many bytes sync counts for one item: the key plus the value's JSON,
|
|
58
|
+
* UTF-8 encoded, as Chrome measures `QUOTA_BYTES_PER_ITEM`.
|
|
59
|
+
*
|
|
60
|
+
* @param {string} key - The item's key.
|
|
61
|
+
* @param {unknown} value - The item's value.
|
|
62
|
+
* @returns {number} Bytes.
|
|
63
|
+
*/
|
|
64
|
+
export function syncItemBytes(key, value) {
|
|
65
|
+
return new TextEncoder().encode(key + JSON.stringify(value)).length;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Whether an object has no own keys.
|
|
70
|
+
*
|
|
71
|
+
* @param {object} object - The object to look at.
|
|
72
|
+
* @returns {boolean} True for `{}`.
|
|
73
|
+
*/
|
|
74
|
+
export function isEmpty(object) {
|
|
75
|
+
return Object.keys(object).length === 0;
|
|
76
|
+
}
|