@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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +217 -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 +222 -0
  35. package/src/testing/e2e.js +327 -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 +29 -0
  39. package/src/testing/index.js +24 -0
  40. package/src/testing/package.d.ts +39 -0
  41. package/src/testing/package.js +96 -0
  42. package/src/testing/server.d.ts +47 -0
  43. package/src/testing/server.js +90 -0
  44. package/src/toolbar/offscreen.d.ts +30 -0
  45. package/src/toolbar/offscreen.js +37 -0
  46. package/src/toolbar/scheme.d.ts +81 -0
  47. package/src/toolbar/scheme.js +87 -0
  48. package/src/toolbar/theme.d.ts +187 -0
  49. package/src/toolbar/theme.js +176 -0
@@ -0,0 +1,87 @@
1
+ /**
2
+ * The page side of the light/dark toolbar glyph: documents tell the service
3
+ * worker whether they see a light or dark colour scheme, so it can swap the
4
+ * glyph (toolbar/theme.js). The worker has no `matchMedia`, so documents
5
+ * report: an extension's pages, its offscreen watcher, or a content script.
6
+ * Kept tiny, as a content script may carry it.
7
+ *
8
+ * @module toolbar/scheme
9
+ */
10
+
11
+ /** `chrome.storage.local`: the scheme last reported. This computer's, never synced. */
12
+ export const TOOLBAR_DARK_KEY = 'toolbarDark';
13
+
14
+ /** The report's message type: `{type: TOOLBAR_SCHEME, dark: boolean}`. */
15
+ export const TOOLBAR_SCHEME = 'toolbar-scheme';
16
+
17
+ /** The media query that matches a dark colour scheme. */
18
+ export const DARK_QUERY = '(prefers-color-scheme: dark)';
19
+
20
+ /**
21
+ * What a document reports through: `chrome.runtime`, or a fake.
22
+ *
23
+ * @typedef {{sendMessage(message: unknown): Promise<unknown>}} MessageSender
24
+ */
25
+
26
+ /**
27
+ * What `watchColorScheme` needs: `chrome` itself will do.
28
+ *
29
+ * @typedef {object} SchemeApi
30
+ * @property {MessageSender} runtime `chrome.runtime`.
31
+ * @property {{local: {get(keys: string): Promise<Record<string, any>>}}} storage `chrome.storage`.
32
+ */
33
+
34
+ /**
35
+ * The smallest `MediaQueryList`: `matchMedia(DARK_QUERY)`.
36
+ *
37
+ * @typedef {Pick<MediaQueryList, 'matches' | 'addEventListener'>} SchemeMedia
38
+ */
39
+
40
+ /**
41
+ * Send `dark` now if it differs from `known` (what's stored), then on each
42
+ * change: a page load where nothing changed sends nothing.
43
+ *
44
+ * @param {SchemeMedia} media - `matchMedia(DARK_QUERY)`.
45
+ * @param {boolean | undefined} known - What the worker last stored;
46
+ * undefined (unknown) sends once.
47
+ * @param {(dark: boolean) => void} send - Delivers a report.
48
+ * @returns {() => void} The check itself, for a document that has to poll
49
+ * (an offscreen document never hears `change`).
50
+ */
51
+ export function reportColorScheme(media, known, send) {
52
+ let last = known;
53
+ const report = () => {
54
+ if (media.matches === last) return;
55
+ last = media.matches;
56
+ send(last);
57
+ };
58
+ report();
59
+ media.addEventListener('change', report);
60
+ return report;
61
+ }
62
+
63
+ /**
64
+ * A sender of scheme reports. The worker may be asleep or restarting: a lost
65
+ * report is retried by the next change or page, so failures are dropped.
66
+ *
67
+ * @param {MessageSender} runtime - `chrome.runtime`.
68
+ * @returns {(dark: boolean) => Promise<unknown>} Sends one report.
69
+ */
70
+ export function schemeSender(runtime) {
71
+ return (dark) => runtime.sendMessage({ type: TOOLBAR_SCHEME, dark }).catch(() => {});
72
+ }
73
+
74
+ /**
75
+ * For an extension page: read what's stored, then report now if it differs
76
+ * and on every change.
77
+ *
78
+ * @param {SchemeApi} api - `chrome`, or a fake with `runtime` and `storage.local`.
79
+ * @param {SchemeMedia} [media] - Default `matchMedia(DARK_QUERY)`.
80
+ * @returns {Promise<void>} Resolves once the first check has run.
81
+ * @example
82
+ * watchColorScheme(chrome).catch((error) => console.debug('Reporting the colour scheme:', error));
83
+ */
84
+ export async function watchColorScheme(api, media = matchMedia(DARK_QUERY)) {
85
+ const { [TOOLBAR_DARK_KEY]: stored } = await api.storage.local.get(TOOLBAR_DARK_KEY);
86
+ reportColorScheme(media, stored === true, schemeSender(api.runtime));
87
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * The worker side of the light/dark toolbar glyph, as Chrome's own toolbar
3
+ * icons do. The service worker can't see the scheme (no `matchMedia`), so
4
+ * documents report it (toolbar/scheme.js); this stores the last report per
5
+ * computer and shows the matching glyph. `setIcon` changes only the image: a
6
+ * badge still draws over it.
7
+ *
8
+ * An extension with nothing of its own on every page can keep an offscreen
9
+ * document open to hear a theme change (`watchFromOffscreen`,
10
+ * toolbar/offscreen.js).
11
+ *
12
+ * @module toolbar/theme
13
+ */
14
+ /** Where the offscreen scheme watcher lives, from the extension's root. */
15
+ export declare const OFFSCREEN_PATH = "offscreen/offscreen.html";
16
+ /** Where the rendered glyphs live (build/toolbar-icons.js writes them). */
17
+ export declare const TOOLBAR_ICON_DIR = "/icons/toolbar";
18
+ export type ToolbarIconPaths = {
19
+ 16: string;
20
+ 32: string;
21
+ 48: string;
22
+ };
23
+ export type ToolbarThemeApi = {
24
+ /**
25
+ * `chrome.storage.local`.
26
+ */
27
+ storage: {
28
+ get(keys: string): Promise<Record<string, any>>;
29
+ set(items: Record<string, unknown>): Promise<void>;
30
+ };
31
+ /**
32
+ * `chrome.action`.
33
+ */
34
+ action: {
35
+ setIcon(details: {
36
+ path: ToolbarIconPaths;
37
+ }): Promise<void>;
38
+ };
39
+ /**
40
+ * `chrome.runtime.id`: reports from anyone else are ignored.
41
+ */
42
+ extensionId: string;
43
+ };
44
+ export type ToolbarTheme = {
45
+ /**
46
+ * Show the dark glyph again if that's what was
47
+ * seen last: after a restart or re-enable Chrome shows the manifest's light one.
48
+ */
49
+ restore: () => Promise<void>;
50
+ /**
51
+ * Handle a
52
+ * scheme report; returns whether it was one (handled or ignored), so a worker's
53
+ * message listener can stop there.
54
+ */
55
+ onMessage: (message: unknown, sender?: {
56
+ id?: string;
57
+ }) => boolean;
58
+ /**
59
+ * Resolves once every report so far is handled.
60
+ */
61
+ settled: () => Promise<void>;
62
+ };
63
+ /**
64
+ * @typedef {{16: string, 32: string, 48: string}} ToolbarIconPaths
65
+ */
66
+ /**
67
+ * The worker APIs the toolbar theme uses: pieces of `chrome`, or fakes.
68
+ *
69
+ * @typedef {object} ToolbarThemeApi
70
+ * @property {{get(keys: string): Promise<Record<string, any>>, set(items: Record<string, unknown>): Promise<void>}} storage
71
+ * `chrome.storage.local`.
72
+ * @property {{setIcon(details: {path: ToolbarIconPaths}): Promise<void>}} action `chrome.action`.
73
+ * @property {string} extensionId `chrome.runtime.id`: reports from anyone else are ignored.
74
+ */
75
+ /**
76
+ * @typedef {object} ToolbarTheme
77
+ * @property {() => Promise<void>} restore Show the dark glyph again if that's what was
78
+ * seen last: after a restart or re-enable Chrome shows the manifest's light one.
79
+ * @property {(message: unknown, sender?: {id?: string}) => boolean} onMessage Handle a
80
+ * scheme report; returns whether it was one (handled or ignored), so a worker's
81
+ * message listener can stop there.
82
+ * @property {() => Promise<void>} settled Resolves once every report so far is handled.
83
+ */
84
+ /**
85
+ * The glyph for a scheme; the manifest's `default_icon` is the light one.
86
+ * Paths start with "/": Chrome resolves `setIcon` paths against the worker's
87
+ * own address, where a relative one fails to load.
88
+ *
89
+ * @param {boolean} dark - The dark scheme's glyph, else the light one's.
90
+ * @returns {ToolbarIconPaths} Paths by size, for `chrome.action.setIcon`.
91
+ * @example
92
+ * toolbarIcon(true); // { 16: '/icons/toolbar/dark-16.png', 32: …, 48: … }
93
+ */
94
+ export declare function toolbarIcon(dark: boolean): ToolbarIconPaths;
95
+ /**
96
+ * The worker's toolbar theme: one report at a time, so two quick ones can't
97
+ * store one scheme and show the other.
98
+ *
99
+ * @param {ToolbarThemeApi} api - Storage, the action and this extension's id.
100
+ * @returns {ToolbarTheme} The theme's handlers.
101
+ */
102
+ export declare function createToolbarTheme({ storage, action, extensionId }: ToolbarThemeApi): ToolbarTheme;
103
+ export type OffscreenApi = {
104
+ /**
105
+ * `chrome.offscreen`.
106
+ */
107
+ offscreen: {
108
+ createDocument(details: {
109
+ url: string;
110
+ reasons: string[];
111
+ justification: string;
112
+ }): Promise<void>;
113
+ Reason: {
114
+ MATCH_MEDIA: string;
115
+ };
116
+ };
117
+ /**
118
+ * `chrome.runtime`.
119
+ */
120
+ runtime: {
121
+ getContexts(filter: {
122
+ contextTypes: 'OFFSCREEN_DOCUMENT'[];
123
+ }): Promise<unknown[]>;
124
+ };
125
+ };
126
+ /**
127
+ * Open the offscreen document that watches the scheme (reason MATCH_MEDIA),
128
+ * unless it's open. It stays open: it's one small page in the extension's own
129
+ * process, and the only way to hear a theme change while no page of ours is
130
+ * open. Chrome closes it when the browser quits, so call this each time the
131
+ * worker starts. Concurrent calls share one attempt.
132
+ *
133
+ * @param {OffscreenApi} api - `chrome.offscreen` and `chrome.runtime`.
134
+ * @param {string} [url] - The document, from the extension's root. Default {@link OFFSCREEN_PATH}.
135
+ * @returns {Promise<boolean>} Whether it opened one (false: already open).
136
+ */
137
+ export declare function watchFromOffscreen({ offscreen, runtime }: OffscreenApi, url?: string): Promise<boolean>;
138
+ export type ToolbarWorkerApi = {
139
+ /**
140
+ * `chrome.storage`.
141
+ */
142
+ storage: {
143
+ local: ToolbarThemeApi['storage'];
144
+ };
145
+ /**
146
+ * `chrome.action`.
147
+ */
148
+ action: ToolbarThemeApi['action'];
149
+ /**
150
+ * `chrome.offscreen`.
151
+ */
152
+ offscreen: OffscreenApi['offscreen'];
153
+ /**
154
+ * `chrome.runtime`.
155
+ */
156
+ runtime: OffscreenApi['runtime'] & {
157
+ id: string;
158
+ onMessage: {
159
+ addListener(listener: (message: unknown, sender: {
160
+ id?: string;
161
+ }) => boolean): void;
162
+ };
163
+ };
164
+ };
165
+ /**
166
+ * Everything `startToolbarTheme` wires up: `chrome` itself will do.
167
+ *
168
+ * @typedef {object} ToolbarWorkerApi
169
+ * @property {{local: ToolbarThemeApi['storage']}} storage `chrome.storage`.
170
+ * @property {ToolbarThemeApi['action']} action `chrome.action`.
171
+ * @property {OffscreenApi['offscreen']} offscreen `chrome.offscreen`.
172
+ * @property {OffscreenApi['runtime'] & {id: string, onMessage: {addListener(listener: (message: unknown, sender: {id?: string}) => boolean): void}}} runtime
173
+ * `chrome.runtime`.
174
+ */
175
+ /**
176
+ * The whole worker side for an extension that watches from an offscreen
177
+ * document: restore the last glyph, open the watcher, and handle its reports.
178
+ * Call it at the worker's top level, so the listener attaches every time the
179
+ * worker wakes.
180
+ *
181
+ * @param {ToolbarWorkerApi} api - `chrome`, or a fake.
182
+ * @returns {ToolbarTheme} The theme, for tests.
183
+ * @example
184
+ * // background.js
185
+ * startToolbarTheme(chrome);
186
+ */
187
+ export declare function startToolbarTheme(api: ToolbarWorkerApi): ToolbarTheme;
@@ -0,0 +1,176 @@
1
+ /**
2
+ * The worker side of the light/dark toolbar glyph, as Chrome's own toolbar
3
+ * icons do. The service worker can't see the scheme (no `matchMedia`), so
4
+ * documents report it (toolbar/scheme.js); this stores the last report per
5
+ * computer and shows the matching glyph. `setIcon` changes only the image: a
6
+ * badge still draws over it.
7
+ *
8
+ * An extension with nothing of its own on every page can keep an offscreen
9
+ * document open to hear a theme change (`watchFromOffscreen`,
10
+ * toolbar/offscreen.js).
11
+ *
12
+ * @module toolbar/theme
13
+ */
14
+
15
+ import { TOOLBAR_DARK_KEY, TOOLBAR_SCHEME } from './scheme.js';
16
+
17
+ /** Where the offscreen scheme watcher lives, from the extension's root. */
18
+ export const OFFSCREEN_PATH = 'offscreen/offscreen.html';
19
+
20
+ /** Where the rendered glyphs live (build/toolbar-icons.js writes them). */
21
+ export const TOOLBAR_ICON_DIR = '/icons/toolbar';
22
+
23
+ /**
24
+ * @typedef {{16: string, 32: string, 48: string}} ToolbarIconPaths
25
+ */
26
+
27
+ /**
28
+ * The worker APIs the toolbar theme uses: pieces of `chrome`, or fakes.
29
+ *
30
+ * @typedef {object} ToolbarThemeApi
31
+ * @property {{get(keys: string): Promise<Record<string, any>>, set(items: Record<string, unknown>): Promise<void>}} storage
32
+ * `chrome.storage.local`.
33
+ * @property {{setIcon(details: {path: ToolbarIconPaths}): Promise<void>}} action `chrome.action`.
34
+ * @property {string} extensionId `chrome.runtime.id`: reports from anyone else are ignored.
35
+ */
36
+
37
+ /**
38
+ * @typedef {object} ToolbarTheme
39
+ * @property {() => Promise<void>} restore Show the dark glyph again if that's what was
40
+ * seen last: after a restart or re-enable Chrome shows the manifest's light one.
41
+ * @property {(message: unknown, sender?: {id?: string}) => boolean} onMessage Handle a
42
+ * scheme report; returns whether it was one (handled or ignored), so a worker's
43
+ * message listener can stop there.
44
+ * @property {() => Promise<void>} settled Resolves once every report so far is handled.
45
+ */
46
+
47
+ /**
48
+ * The glyph for a scheme; the manifest's `default_icon` is the light one.
49
+ * Paths start with "/": Chrome resolves `setIcon` paths against the worker's
50
+ * own address, where a relative one fails to load.
51
+ *
52
+ * @param {boolean} dark - The dark scheme's glyph, else the light one's.
53
+ * @returns {ToolbarIconPaths} Paths by size, for `chrome.action.setIcon`.
54
+ * @example
55
+ * toolbarIcon(true); // { 16: '/icons/toolbar/dark-16.png', 32: …, 48: … }
56
+ */
57
+ export function toolbarIcon(dark) {
58
+ const scheme = dark ? 'dark' : 'light';
59
+ return {
60
+ 16: `${TOOLBAR_ICON_DIR}/${scheme}-16.png`,
61
+ 32: `${TOOLBAR_ICON_DIR}/${scheme}-32.png`,
62
+ 48: `${TOOLBAR_ICON_DIR}/${scheme}-48.png`,
63
+ };
64
+ }
65
+
66
+ /**
67
+ * The worker's toolbar theme: one report at a time, so two quick ones can't
68
+ * store one scheme and show the other.
69
+ *
70
+ * @param {ToolbarThemeApi} api - Storage, the action and this extension's id.
71
+ * @returns {ToolbarTheme} The theme's handlers.
72
+ */
73
+ export function createToolbarTheme({ storage, action, extensionId }) {
74
+ /** @type {Promise<void>} */
75
+ let queue = Promise.resolve();
76
+ /** @param {boolean} dark */
77
+ const show = (dark) => action.setIcon({ path: toolbarIcon(dark) });
78
+
79
+ return {
80
+ restore() {
81
+ queue = queue.then(async () => {
82
+ const { [TOOLBAR_DARK_KEY]: dark } = await storage.get(TOOLBAR_DARK_KEY);
83
+ if (dark === true) await show(true);
84
+ }).catch((error) => console.error('Restoring the toolbar icon:', error));
85
+ return queue;
86
+ },
87
+
88
+ onMessage(message, sender) {
89
+ const report = /** @type {{type?: unknown, dark?: unknown} | null | undefined} */ (message);
90
+ if (report?.type !== TOOLBAR_SCHEME) return false;
91
+ const { dark } = report;
92
+ if (sender?.id !== extensionId || typeof dark !== 'boolean') return true;
93
+ queue = queue.then(async () => {
94
+ const { [TOOLBAR_DARK_KEY]: was = false } = await storage.get(TOOLBAR_DARK_KEY);
95
+ if (was === dark) return;
96
+ // Shown first: if Chrome refuses the icon, nothing is stored, and the next report tries again.
97
+ await show(dark);
98
+ await storage.set({ [TOOLBAR_DARK_KEY]: dark });
99
+ }).catch((error) => console.error('Changing the toolbar icon:', error));
100
+ return true;
101
+ },
102
+
103
+ settled: () => queue,
104
+ };
105
+ }
106
+
107
+ /**
108
+ * What opening the offscreen watcher needs: pieces of `chrome`, or fakes.
109
+ *
110
+ * @typedef {object} OffscreenApi
111
+ * @property {{createDocument(details: {url: string, reasons: string[], justification: string}): Promise<void>, Reason: {MATCH_MEDIA: string}}} offscreen
112
+ * `chrome.offscreen`.
113
+ * @property {{getContexts(filter: {contextTypes: 'OFFSCREEN_DOCUMENT'[]}): Promise<unknown[]>}} runtime `chrome.runtime`.
114
+ */
115
+
116
+ /** @type {Promise<boolean> | null} */
117
+ let opening = null;
118
+
119
+ /**
120
+ * Open the offscreen document that watches the scheme (reason MATCH_MEDIA),
121
+ * unless it's open. It stays open: it's one small page in the extension's own
122
+ * process, and the only way to hear a theme change while no page of ours is
123
+ * open. Chrome closes it when the browser quits, so call this each time the
124
+ * worker starts. Concurrent calls share one attempt.
125
+ *
126
+ * @param {OffscreenApi} api - `chrome.offscreen` and `chrome.runtime`.
127
+ * @param {string} [url] - The document, from the extension's root. Default {@link OFFSCREEN_PATH}.
128
+ * @returns {Promise<boolean>} Whether it opened one (false: already open).
129
+ */
130
+ export function watchFromOffscreen({ offscreen, runtime }, url = OFFSCREEN_PATH) {
131
+ opening ??= (async () => {
132
+ const open = await runtime.getContexts({ contextTypes: ['OFFSCREEN_DOCUMENT'] });
133
+ if (open.length) return false;
134
+ await offscreen.createDocument({
135
+ url,
136
+ reasons: [offscreen.Reason.MATCH_MEDIA],
137
+ justification: 'Reads whether Chrome is in light or dark mode, to show the matching toolbar icon.',
138
+ });
139
+ return true;
140
+ })().finally(() => { opening = null; });
141
+ return opening;
142
+ }
143
+
144
+ /**
145
+ * Everything `startToolbarTheme` wires up: `chrome` itself will do.
146
+ *
147
+ * @typedef {object} ToolbarWorkerApi
148
+ * @property {{local: ToolbarThemeApi['storage']}} storage `chrome.storage`.
149
+ * @property {ToolbarThemeApi['action']} action `chrome.action`.
150
+ * @property {OffscreenApi['offscreen']} offscreen `chrome.offscreen`.
151
+ * @property {OffscreenApi['runtime'] & {id: string, onMessage: {addListener(listener: (message: unknown, sender: {id?: string}) => boolean): void}}} runtime
152
+ * `chrome.runtime`.
153
+ */
154
+
155
+ /**
156
+ * The whole worker side for an extension that watches from an offscreen
157
+ * document: restore the last glyph, open the watcher, and handle its reports.
158
+ * Call it at the worker's top level, so the listener attaches every time the
159
+ * worker wakes.
160
+ *
161
+ * @param {ToolbarWorkerApi} api - `chrome`, or a fake.
162
+ * @returns {ToolbarTheme} The theme, for tests.
163
+ * @example
164
+ * // background.js
165
+ * startToolbarTheme(chrome);
166
+ */
167
+ export function startToolbarTheme(api) {
168
+ const theme = createToolbarTheme({ storage: api.storage.local, action: api.action, extensionId: api.runtime.id });
169
+ theme.restore();
170
+ watchFromOffscreen(api).catch((error) => console.error('Opening the colour scheme watcher:', error));
171
+ api.runtime.onMessage.addListener((message, sender) => {
172
+ theme.onMessage(message, sender);
173
+ return false;
174
+ });
175
+ return theme;
176
+ }