@arraypress/extension-kit 0.0.0-stage → 1.0.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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +214 -2
  3. package/bin/extension-kit.js +35 -0
  4. package/package.json +67 -4
  5. package/src/about/links.d.ts +51 -0
  6. package/src/about/links.js +48 -0
  7. package/src/build/duplication.d.ts +116 -0
  8. package/src/build/duplication.js +119 -0
  9. package/src/build/index.d.ts +11 -0
  10. package/src/build/index.js +12 -0
  11. package/src/build/release-checks.d.ts +36 -0
  12. package/src/build/release-checks.js +61 -0
  13. package/src/build/toolbar-icons.d.ts +96 -0
  14. package/src/build/toolbar-icons.js +101 -0
  15. package/src/build/vendor.d.ts +26 -0
  16. package/src/build/vendor.js +43 -0
  17. package/src/build/zip.d.ts +31 -0
  18. package/src/build/zip.js +47 -0
  19. package/src/commands/shortcuts.d.ts +39 -0
  20. package/src/commands/shortcuts.js +31 -0
  21. package/src/dom/i18n.d.ts +39 -0
  22. package/src/dom/i18n.js +49 -0
  23. package/src/dom/page.d.ts +59 -0
  24. package/src/dom/page.js +93 -0
  25. package/src/index.d.ts +21 -0
  26. package/src/index.js +24 -0
  27. package/src/settings/saver.d.ts +76 -0
  28. package/src/settings/saver.js +98 -0
  29. package/src/settings/storage.d.ts +91 -0
  30. package/src/settings/storage.js +76 -0
  31. package/src/settings/store.d.ts +138 -0
  32. package/src/settings/store.js +173 -0
  33. package/src/styles/chrome-ui.css +1094 -0
  34. package/src/testing/e2e.d.ts +165 -0
  35. package/src/testing/e2e.js +272 -0
  36. package/src/testing/fake-storage.d.ts +71 -0
  37. package/src/testing/fake-storage.js +145 -0
  38. package/src/testing/index.d.ts +9 -0
  39. package/src/testing/index.js +10 -0
  40. package/src/testing/package.d.ts +39 -0
  41. package/src/testing/package.js +96 -0
  42. package/src/toolbar/offscreen.d.ts +30 -0
  43. package/src/toolbar/offscreen.js +37 -0
  44. package/src/toolbar/scheme.d.ts +81 -0
  45. package/src/toolbar/scheme.js +87 -0
  46. package/src/toolbar/theme.d.ts +187 -0
  47. package/src/toolbar/theme.js +176 -0
@@ -0,0 +1,138 @@
1
+ /**
2
+ * A settings store on `chrome.storage.sync`, one item per setting, like
3
+ * Chrome's own settings: they follow the user to their other computers
4
+ * through their own Chrome Sync, and the developer receives nothing.
5
+ *
6
+ * Settings tied to one computer (say, ones that need a site permission Chrome
7
+ * grants per computer) are named in `localKeys` and kept in
8
+ * `chrome.storage.local` instead. Local also holds any write sync refused
9
+ * (too many writes a minute, or an item over its size limit), kept under
10
+ * `unsyncedKey`, read as if synced, and retried, so a change is never lost.
11
+ *
12
+ * Each extension supplies its schema: the defaults and one normaliser that
13
+ * coerces anything read from storage or a form into valid settings.
14
+ *
15
+ * @module settings/store
16
+ */
17
+ import type { StorageChange, SyncedStorage } from './storage.js';
18
+ export type SettingsSchema<S extends Record<string, unknown>> = {
19
+ /**
20
+ * Every setting and its default value.
21
+ */
22
+ defaults: Readonly<S>;
23
+ /**
24
+ * Coerce anything
25
+ * (stored, typed, from a newer version) into valid settings, falling back
26
+ * to the default for what it can't read. Must handle `{}`.
27
+ */
28
+ normalize: (raw: Record<string, unknown>) => S;
29
+ /**
30
+ * Settings kept in
31
+ * `chrome.storage.local`, tied to this computer. Default none.
32
+ */
33
+ localKeys?: readonly (keyof S & string)[];
34
+ /**
35
+ * The local item holding writes sync
36
+ * refused, `{key: value}`. Default `'unsyncedSettings'`.
37
+ */
38
+ unsyncedKey?: string;
39
+ };
40
+ export type SettingsStore<S extends Record<string, unknown>> = {
41
+ /**
42
+ * The schema's defaults.
43
+ */
44
+ defaults: Readonly<S>;
45
+ /**
46
+ * Settings kept per computer.
47
+ */
48
+ localKeys: readonly (keyof S & string)[];
49
+ /**
50
+ * Settings kept in Chrome Sync.
51
+ */
52
+ syncedKeys: readonly (keyof S & string)[];
53
+ /**
54
+ * The local item holding writes sync refused.
55
+ */
56
+ unsyncedKey: string;
57
+ /**
58
+ * The
59
+ * schema's normaliser; `undefined` or `null` read as `{}`.
60
+ */
61
+ normalize: (raw?: Record<string, unknown> | null) => S;
62
+ /**
63
+ * Read every setting.
64
+ */
65
+ load: (storage: SyncedStorage) => Promise<S>;
66
+ /**
67
+ * Write synced settings, then retry earlier refusals; resolves to whether
68
+ * everything reached sync.
69
+ */
70
+ writeSynced: (storage: SyncedStorage, values: Partial<S>) => Promise<boolean>;
71
+ /**
72
+ * Try again to put earlier refusals into sync.
73
+ */
74
+ retryUnsynced: (storage: SyncedStorage) => Promise<boolean>;
75
+ /**
76
+ * Save the settings `changes` names, normalised.
77
+ */
78
+ save: (storage: SyncedStorage, changes: Partial<S>) => Promise<{
79
+ settings: S;
80
+ synced: boolean;
81
+ }>;
82
+ /**
83
+ * The settings a `chrome.storage.onChanged` event touches.
84
+ */
85
+ changed: (changes: Record<string, StorageChange>, area: string) => (keyof S & string)[];
86
+ };
87
+ /** @import { StorageChange, SyncedStorage } from './storage.js' */
88
+ /**
89
+ * What an extension's settings are.
90
+ *
91
+ * @template {Record<string, unknown>} S
92
+ * @typedef {object} SettingsSchema
93
+ * @property {Readonly<S>} defaults Every setting and its default value.
94
+ * @property {(raw: Record<string, unknown>) => S} normalize Coerce anything
95
+ * (stored, typed, from a newer version) into valid settings, falling back
96
+ * to the default for what it can't read. Must handle `{}`.
97
+ * @property {readonly (keyof S & string)[]} [localKeys] Settings kept in
98
+ * `chrome.storage.local`, tied to this computer. Default none.
99
+ * @property {string} [unsyncedKey] The local item holding writes sync
100
+ * refused, `{key: value}`. Default `'unsyncedSettings'`.
101
+ */
102
+ /**
103
+ * A settings store made by {@link createSettingsStore}.
104
+ *
105
+ * @template {Record<string, unknown>} S
106
+ * @typedef {object} SettingsStore
107
+ * @property {Readonly<S>} defaults The schema's defaults.
108
+ * @property {readonly (keyof S & string)[]} localKeys Settings kept per computer.
109
+ * @property {readonly (keyof S & string)[]} syncedKeys Settings kept in Chrome Sync.
110
+ * @property {string} unsyncedKey The local item holding writes sync refused.
111
+ * @property {(raw?: Record<string, unknown> | null) => S} normalize The
112
+ * schema's normaliser; `undefined` or `null` read as `{}`.
113
+ * @property {(storage: SyncedStorage) => Promise<S>} load Read every setting.
114
+ * @property {(storage: SyncedStorage, values: Partial<S>) => Promise<boolean>} writeSynced
115
+ * Write synced settings, then retry earlier refusals; resolves to whether
116
+ * everything reached sync.
117
+ * @property {(storage: SyncedStorage) => Promise<boolean>} retryUnsynced
118
+ * Try again to put earlier refusals into sync.
119
+ * @property {(storage: SyncedStorage, changes: Partial<S>) => Promise<{settings: S, synced: boolean}>} save
120
+ * Save the settings `changes` names, normalised.
121
+ * @property {(changes: Record<string, StorageChange>, area: string) => (keyof S & string)[]} changed
122
+ * The settings a `chrome.storage.onChanged` event touches.
123
+ */
124
+ /**
125
+ * Make a settings store for an extension's schema.
126
+ *
127
+ * @template {Record<string, unknown>} S
128
+ * @param {SettingsSchema<S>} schema - The defaults, the normaliser and where each setting lives.
129
+ * @returns {SettingsStore<S>} The store; every method takes `chrome.storage` (or a fake).
130
+ * @example
131
+ * const store = createSettingsStore({
132
+ * defaults: { days: 7 },
133
+ * normalize: (raw) => ({ days: Number.isInteger(raw.days) ? raw.days : 7 }),
134
+ * });
135
+ * const settings = await store.load(chrome.storage);
136
+ * await store.save(chrome.storage, { days: 14 });
137
+ */
138
+ export declare function createSettingsStore<S extends Record<string, unknown>>({ defaults, normalize: normalizeRaw, localKeys, unsyncedKey }: SettingsSchema<S>): SettingsStore<S>;
@@ -0,0 +1,173 @@
1
+ /**
2
+ * A settings store on `chrome.storage.sync`, one item per setting, like
3
+ * Chrome's own settings: they follow the user to their other computers
4
+ * through their own Chrome Sync, and the developer receives nothing.
5
+ *
6
+ * Settings tied to one computer (say, ones that need a site permission Chrome
7
+ * grants per computer) are named in `localKeys` and kept in
8
+ * `chrome.storage.local` instead. Local also holds any write sync refused
9
+ * (too many writes a minute, or an item over its size limit), kept under
10
+ * `unsyncedKey`, read as if synced, and retried, so a change is never lost.
11
+ *
12
+ * Each extension supplies its schema: the defaults and one normaliser that
13
+ * coerces anything read from storage or a form into valid settings.
14
+ *
15
+ * @module settings/store
16
+ */
17
+
18
+ import { MAX_SYNC_ITEM_BYTES, isEmpty, pick, syncItemBytes } from './storage.js';
19
+
20
+ /** @import { StorageChange, SyncedStorage } from './storage.js' */
21
+
22
+ /**
23
+ * What an extension's settings are.
24
+ *
25
+ * @template {Record<string, unknown>} S
26
+ * @typedef {object} SettingsSchema
27
+ * @property {Readonly<S>} defaults Every setting and its default value.
28
+ * @property {(raw: Record<string, unknown>) => S} normalize Coerce anything
29
+ * (stored, typed, from a newer version) into valid settings, falling back
30
+ * to the default for what it can't read. Must handle `{}`.
31
+ * @property {readonly (keyof S & string)[]} [localKeys] Settings kept in
32
+ * `chrome.storage.local`, tied to this computer. Default none.
33
+ * @property {string} [unsyncedKey] The local item holding writes sync
34
+ * refused, `{key: value}`. Default `'unsyncedSettings'`.
35
+ */
36
+
37
+ /**
38
+ * A settings store made by {@link createSettingsStore}.
39
+ *
40
+ * @template {Record<string, unknown>} S
41
+ * @typedef {object} SettingsStore
42
+ * @property {Readonly<S>} defaults The schema's defaults.
43
+ * @property {readonly (keyof S & string)[]} localKeys Settings kept per computer.
44
+ * @property {readonly (keyof S & string)[]} syncedKeys Settings kept in Chrome Sync.
45
+ * @property {string} unsyncedKey The local item holding writes sync refused.
46
+ * @property {(raw?: Record<string, unknown> | null) => S} normalize The
47
+ * schema's normaliser; `undefined` or `null` read as `{}`.
48
+ * @property {(storage: SyncedStorage) => Promise<S>} load Read every setting.
49
+ * @property {(storage: SyncedStorage, values: Partial<S>) => Promise<boolean>} writeSynced
50
+ * Write synced settings, then retry earlier refusals; resolves to whether
51
+ * everything reached sync.
52
+ * @property {(storage: SyncedStorage) => Promise<boolean>} retryUnsynced
53
+ * Try again to put earlier refusals into sync.
54
+ * @property {(storage: SyncedStorage, changes: Partial<S>) => Promise<{settings: S, synced: boolean}>} save
55
+ * Save the settings `changes` names, normalised.
56
+ * @property {(changes: Record<string, StorageChange>, area: string) => (keyof S & string)[]} changed
57
+ * The settings a `chrome.storage.onChanged` event touches.
58
+ */
59
+
60
+ /**
61
+ * Make a settings store for an extension's schema.
62
+ *
63
+ * @template {Record<string, unknown>} S
64
+ * @param {SettingsSchema<S>} schema - The defaults, the normaliser and where each setting lives.
65
+ * @returns {SettingsStore<S>} The store; every method takes `chrome.storage` (or a fake).
66
+ * @example
67
+ * const store = createSettingsStore({
68
+ * defaults: { days: 7 },
69
+ * normalize: (raw) => ({ days: Number.isInteger(raw.days) ? raw.days : 7 }),
70
+ * });
71
+ * const settings = await store.load(chrome.storage);
72
+ * await store.save(chrome.storage, { days: 14 });
73
+ */
74
+ export function createSettingsStore({ defaults, normalize: normalizeRaw, localKeys = [], unsyncedKey = 'unsyncedSettings' }) {
75
+ const allKeys = /** @type {(keyof S & string)[]} */ (Object.keys(defaults));
76
+ const syncedKeys = Object.freeze(allKeys.filter((key) => !localKeys.includes(key)));
77
+
78
+ /** @param {Record<string, unknown> | null} [raw] @returns {S} */
79
+ const normalize = (raw) => normalizeRaw(raw ?? {});
80
+
81
+ /**
82
+ * Start one sync write of `values` at once; resolves to what sync refused.
83
+ * An item too big for sync isn't even tried.
84
+ *
85
+ * @param {SyncedStorage} storage
86
+ * @param {Record<string, unknown>} values
87
+ * @returns {Promise<Record<string, unknown>>}
88
+ */
89
+ function send(storage, values) {
90
+ const entries = Object.entries(values);
91
+ const fits = Object.fromEntries(entries.filter(([key, value]) => syncItemBytes(key, value) <= MAX_SYNC_ITEM_BYTES));
92
+ const refused = Object.fromEntries(entries.filter(([key]) => !(key in fits)));
93
+ if (isEmpty(fits)) return Promise.resolve(refused);
94
+ return storage.sync.set(fits).then(() => refused, (/** @type {unknown} */ error) => {
95
+ console.warn('Settings: Chrome Sync refused the change; keeping it on this computer and trying again later.',
96
+ /** @type {Error | undefined} */ (error)?.message ?? error);
97
+ return { ...refused, ...fits };
98
+ });
99
+ }
100
+
101
+ /** @type {SettingsStore<S>['load']} */
102
+ async function load(storage) {
103
+ const [synced, local] = await Promise.all([
104
+ storage.sync.get([...syncedKeys]),
105
+ storage.local.get([...localKeys, unsyncedKey]),
106
+ ]);
107
+ const { [unsyncedKey]: unsynced = {}, ...here } = local;
108
+ return normalize({ ...synced, ...pick(unsynced, syncedKeys), ...here });
109
+ }
110
+
111
+ /**
112
+ * One sync write for all of `values` (sync allows 120 a minute), then any
113
+ * that sync refused before. What sync refuses is kept in local, where
114
+ * `load` still reads it, and goes up with the next write or
115
+ * `retryUnsynced`.
116
+ *
117
+ * @type {SettingsStore<S>['writeSynced']}
118
+ */
119
+ async function writeSynced(storage, values) {
120
+ // Sent before anything is awaited, so a page that's closing still gets it out.
121
+ const sending = send(storage, values);
122
+ const { [unsyncedKey]: held } = await storage.local.get([unsyncedKey]);
123
+ let refused = await sending;
124
+ const older = Object.fromEntries(Object.entries(pick(held ?? {}, syncedKeys)).filter(([key]) => !(key in values)));
125
+ if (!isEmpty(older)) refused = { ...(await send(storage, older)), ...refused };
126
+ if (!isEmpty(refused)) await storage.local.set({ [unsyncedKey]: refused });
127
+ else if (held) await storage.local.remove(unsyncedKey);
128
+ return isEmpty(refused);
129
+ }
130
+
131
+ /**
132
+ * Only the settings `changes` names are written, so a change made meanwhile
133
+ * on another computer to a different setting survives. Each setting
134
+ * normalises on its own, so nothing is read first and the writes start at
135
+ * once.
136
+ *
137
+ * @type {SettingsStore<S>['save']}
138
+ */
139
+ async function save(storage, changes) {
140
+ const values = pick(normalize(changes), Object.keys(changes));
141
+ const local = pick(values, localKeys);
142
+ const writingLocal = isEmpty(local) ? null : storage.local.set(local);
143
+ const synced = await writeSynced(storage, pick(values, syncedKeys));
144
+ await writingLocal;
145
+ return { settings: await load(storage), synced };
146
+ }
147
+
148
+ /**
149
+ * Includes settings held for sync in local, which `load` reads too.
150
+ *
151
+ * @type {SettingsStore<S>['changed']}
152
+ */
153
+ function changed(changes, area) {
154
+ if (area === 'sync') return syncedKeys.filter((key) => key in changes);
155
+ if (area !== 'local') return [];
156
+ const heldChange = changes[unsyncedKey];
157
+ const held = heldChange ? Object.keys({ ...(/** @type {object} */ (heldChange.oldValue)), ...(/** @type {object} */ (heldChange.newValue)) }) : [];
158
+ return allKeys.filter((key) => (localKeys.includes(key) ? key in changes : held.includes(key)));
159
+ }
160
+
161
+ return Object.freeze({
162
+ defaults,
163
+ localKeys: Object.freeze([...localKeys]),
164
+ syncedKeys,
165
+ unsyncedKey,
166
+ normalize,
167
+ load,
168
+ writeSynced,
169
+ retryUnsynced: (/** @type {SyncedStorage} */ storage) => writeSynced(storage, {}),
170
+ save,
171
+ changed,
172
+ });
173
+ }