@arraypress/extension-kit 1.10.0 → 1.11.1

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 (69) hide show
  1. package/README.md +103 -21
  2. package/bin/extension-kit.js +2 -1
  3. package/package.json +1 -1
  4. package/src/ai/host.d.ts +183 -0
  5. package/src/ai/host.js +225 -0
  6. package/src/ai/model.d.ts +2 -1
  7. package/src/ai/model.js +10 -2
  8. package/src/build/vendor.d.ts +29 -8
  9. package/src/build/vendor.js +136 -40
  10. package/src/content/highlight.d.ts +15 -6
  11. package/src/content/highlight.js +42 -17
  12. package/src/content/overlay.d.ts +19 -4
  13. package/src/content/overlay.js +44 -6
  14. package/src/content/slices.d.ts +3 -1
  15. package/src/content/slices.js +29 -14
  16. package/src/content/watch.d.ts +4 -2
  17. package/src/content/watch.js +17 -3
  18. package/src/dom/focus.d.ts +11 -0
  19. package/src/dom/focus.js +15 -0
  20. package/src/frame/window.d.ts +1 -1
  21. package/src/frame/window.js +8 -7
  22. package/src/index.d.ts +10 -3
  23. package/src/index.js +8 -3
  24. package/src/panel/show-on-page.d.ts +82 -0
  25. package/src/panel/show-on-page.js +83 -0
  26. package/src/scripting/call.d.ts +29 -9
  27. package/src/scripting/call.js +35 -11
  28. package/src/scripting/context-menus.d.ts +45 -0
  29. package/src/scripting/context-menus.js +53 -0
  30. package/src/scripting/registration.d.ts +2 -1
  31. package/src/scripting/registration.js +2 -1
  32. package/src/scripting/tab.d.ts +17 -5
  33. package/src/scripting/tab.js +15 -7
  34. package/src/settings/saver.js +16 -6
  35. package/src/settings/storage.d.ts +33 -2
  36. package/src/settings/storage.js +73 -3
  37. package/src/settings/store.d.ts +17 -0
  38. package/src/settings/store.js +87 -20
  39. package/src/sites/sites.d.ts +6 -1
  40. package/src/sites/sites.js +20 -3
  41. package/src/styles/chrome-ui.css +59 -30
  42. package/src/testing/e2e.d.ts +35 -4
  43. package/src/testing/e2e.js +67 -5
  44. package/src/testing/fake-model.d.ts +9 -1
  45. package/src/testing/fake-model.js +44 -23
  46. package/src/testing/fake-storage.d.ts +41 -13
  47. package/src/testing/fake-storage.js +163 -51
  48. package/src/testing/index.d.ts +3 -3
  49. package/src/testing/index.js +5 -3
  50. package/src/testing/live-run.d.ts +23 -2
  51. package/src/testing/live-run.js +64 -18
  52. package/src/testing/messages.d.ts +7 -3
  53. package/src/testing/messages.js +50 -9
  54. package/src/testing/package.d.ts +56 -7
  55. package/src/testing/package.js +57 -18
  56. package/src/testing/prompt.d.ts +4 -2
  57. package/src/testing/prompt.js +7 -3
  58. package/src/time/pool.d.ts +2 -1
  59. package/src/time/pool.js +4 -2
  60. package/src/toolbar/theme.d.ts +10 -2
  61. package/src/toolbar/theme.js +12 -4
  62. package/src/ui/menu.d.ts +13 -3
  63. package/src/ui/menu.js +48 -11
  64. package/src/ui/model-row.js +5 -1
  65. package/src/ui/site-list.js +12 -3
  66. package/src/ui/text-dialog.d.ts +3 -2
  67. package/src/ui/text-dialog.js +10 -3
  68. package/src/ui/toast.d.ts +3 -0
  69. package/src/ui/toast.js +23 -1
package/src/ai/host.js ADDED
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Chrome's on-device model for a service worker, wherever Chrome puts the
3
+ * Prompt API. Chrome's documentation says `LanguageModel` isn't in workers
4
+ * (https://developer.chrome.com/docs/ai/prompt-api); Chrome 157 and Chromium
5
+ * 156 do have it in an extension's service worker (checked in both: an
6
+ * unpacked extension, `typeof LanguageModel` in the worker, an extension page
7
+ * and an offscreen document, `'function'` in all three). So neither is
8
+ * promised, and a worker that uses the model shouldn't depend on which holds:
9
+ * {@link createHostedModelSession} is a `ModelSession` that asks the worker's
10
+ * own `LanguageModel` when there is one, and otherwise the extension's
11
+ * offscreen document, which has a page's Prompt API and answers over
12
+ * `chrome.runtime` messages ({@link serveModelOffscreen}).
13
+ *
14
+ * The offscreen document is the one the worker already keeps for the toolbar
15
+ * glyph (an extension gets only one): its script calls both
16
+ * `watchSchemeOffscreen` and `serveModelOffscreen`. It's told the session's
17
+ * options and instructions with each request, so it needs none of the
18
+ * extension's own code.
19
+ *
20
+ * @module ai/host
21
+ */
22
+
23
+ import { within } from '../time/time.js';
24
+ import { chromeLanguageModel, createModelSession } from './model.js';
25
+
26
+ /** @import { AskOptions, Availability, InitialPrompt, LanguageModelOptions, LanguageModelStatic, ModelAnswer, ModelSession } from './model.js' */
27
+
28
+ /** The request's message type: `{type: MODEL_HOST, method, name, …}`. */
29
+ export const MODEL_HOST = 'model-host';
30
+
31
+ /** How long past an answer's time limit the worker waits for the offscreen document to say so itself (ms). */
32
+ export const HOST_GRACE_MS = 1000;
33
+
34
+ /** The model's states, to check an answer that came over a message. */
35
+ const STATES = ['available', 'downloadable', 'downloading', 'unavailable', 'missing'];
36
+
37
+ /** Why a question got no answer, to check the same. */
38
+ const REASONS = ['unavailable', 'late', 'aborted', 'failed'];
39
+
40
+ /**
41
+ * A request to the offscreen document.
42
+ *
43
+ * @typedef {object} HostRequest
44
+ * @property {typeof MODEL_HOST} type The message type.
45
+ * @property {'availability' | 'ask' | 'abort' | 'release'} method What's asked.
46
+ * @property {string} name The session's name (one base session each).
47
+ * @property {LanguageModelOptions} [options] What its sessions are made with.
48
+ * @property {InitialPrompt[]} [initialPrompts] Its instructions and examples.
49
+ * @property {string} [id] A question's id, for `abort`.
50
+ * @property {string} [prompt] The question.
51
+ * @property {number} [timeoutMs] Its time limit.
52
+ * @property {object} [responseConstraint] The answer's JSON Schema.
53
+ */
54
+
55
+ /**
56
+ * The parts of `chrome.runtime` the worker's side uses.
57
+ *
58
+ * @typedef {{sendMessage(message: unknown): Promise<unknown>}} HostSender
59
+ */
60
+
61
+ /**
62
+ * The parts of `chrome.runtime` the offscreen document's side uses.
63
+ *
64
+ * @typedef {object} HostRuntime
65
+ * @property {string} id This extension's id: requests from anyone else are ignored.
66
+ * @property {{addListener(listener: (message: unknown, sender: {id?: string, tab?: unknown}, respond: (answer: unknown) => void) => boolean): void}} onMessage
67
+ * `chrome.runtime.onMessage`.
68
+ */
69
+
70
+ /**
71
+ * @typedef {object} HostedSessionSetup
72
+ * @property {string} name The session's name, its own among the extension's sessions ('topics').
73
+ * @property {LanguageModelOptions} options What sessions are made (and availability asked) with.
74
+ * @property {InitialPrompt[]} [initialPrompts] The instructions and examples every question starts from.
75
+ * @property {HostSender} runtime `chrome.runtime`.
76
+ * @property {() => Promise<unknown>} open Makes sure the offscreen document is open
77
+ * (`() => watchFromOffscreen(chrome)`); only called when the worker has no Prompt API.
78
+ * @property {() => LanguageModelStatic | undefined} [model] The worker's own Prompt API, read when used.
79
+ * Default Chrome's.
80
+ */
81
+
82
+ /**
83
+ * A model session for a service worker: the worker's own Prompt API when it
84
+ * has one, else the offscreen document's. The same `ModelSession` either way.
85
+ * An answer's JSON Schema crosses to the offscreen document as JSON, so it
86
+ * must be a plain object there (not a RegExp).
87
+ *
88
+ * @param {HostedSessionSetup} setup - The session, and how to reach the offscreen document.
89
+ * @returns {ModelSession} The session.
90
+ * @example
91
+ * // background.js
92
+ * const words = createHostedModelSession({
93
+ * name: 'words', options: ENGLISH, initialPrompts: [{ role: 'system', content: 'You list words.' }],
94
+ * runtime: chrome.runtime, open: () => watchFromOffscreen(chrome),
95
+ * });
96
+ * const { text } = await words.ask('Topic: football', { timeoutMs: 20_000 });
97
+ */
98
+ export function createHostedModelSession({ name, options, initialPrompts = [], runtime, open, model = chromeLanguageModel }) {
99
+ const local = createModelSession({ model, options, initialPrompts });
100
+ let asked = 0;
101
+
102
+ /**
103
+ * Ask the offscreen document.
104
+ *
105
+ * @param {HostRequest['method']} method
106
+ * @param {Partial<HostRequest>} [more]
107
+ * @returns {Promise<unknown>} Its answer; rejects when there's no document to hear it.
108
+ */
109
+ const send = async (method, more = {}) => {
110
+ await open();
111
+ return runtime.sendMessage(/** @type {HostRequest} */ ({ type: MODEL_HOST, method, name, options, initialPrompts, ...more }));
112
+ };
113
+
114
+ return {
115
+ async availability() {
116
+ if (model()) return local.availability();
117
+ const state = await send('availability').catch(() => 'missing');
118
+ return STATES.includes(/** @type {string} */ (state)) ? /** @type {Availability} */ (state) : 'missing';
119
+ },
120
+
121
+ async ask(prompt, askOptions) {
122
+ if (model()) return local.ask(prompt, askOptions);
123
+ const { timeoutMs, signal, responseConstraint } = askOptions;
124
+ if (signal?.aborted) return { text: null, reason: 'aborted' };
125
+ const id = `${name}:${Date.now()}:${asked++}`;
126
+ const onAbort = () => { runtime.sendMessage({ type: MODEL_HOST, method: 'abort', name, id }).catch(() => {}); };
127
+ signal?.addEventListener('abort', onAbort, { once: true });
128
+ try {
129
+ // The document keeps the time limit; the worker's own is for a document that never answers.
130
+ const answer = /** @type {Partial<ModelAnswer> | 'gone' | 'late' | undefined} */ (await within(
131
+ send('ask', { id, prompt, timeoutMs, ...(responseConstraint && { responseConstraint }) }).catch(() => 'gone'),
132
+ timeoutMs + HOST_GRACE_MS,
133
+ 'late',
134
+ ));
135
+ if (answer === 'late') {
136
+ onAbort();
137
+ return { text: null, reason: signal?.aborted ? 'aborted' : 'late' };
138
+ }
139
+ if (answer && typeof answer === 'object') {
140
+ if (typeof answer.text === 'string' && answer.reason === 'answered') return { text: answer.text, reason: 'answered' };
141
+ if (REASONS.includes(/** @type {string} */ (answer.reason))) return { text: null, reason: /** @type {Exclude<ModelAnswer['reason'], 'answered'>} */ (answer.reason) };
142
+ }
143
+ return { text: null, reason: signal?.aborted ? 'aborted' : 'unavailable' };
144
+ } finally {
145
+ signal?.removeEventListener('abort', onAbort);
146
+ }
147
+ },
148
+
149
+ release() {
150
+ local.release();
151
+ if (!model()) runtime.sendMessage({ type: MODEL_HOST, method: 'release', name }).catch(() => {});
152
+ },
153
+ };
154
+ }
155
+
156
+ /**
157
+ * Answer the worker's model requests, in the offscreen document: one base
158
+ * session per name, made with the options and instructions the request
159
+ * carries (and again if they change). Requests from another extension, or
160
+ * from a content script (a web page's side), are ignored.
161
+ *
162
+ * @param {HostRuntime} runtime - `chrome.runtime`.
163
+ * @param {{model?: () => LanguageModelStatic | undefined}} [options] - The document's Prompt API. Default Chrome's.
164
+ * @returns {void}
165
+ * @example
166
+ * // offscreen/offscreen.js
167
+ * watchSchemeOffscreen(chrome.runtime);
168
+ * serveModelOffscreen(chrome.runtime);
169
+ */
170
+ export function serveModelOffscreen(runtime, { model = chromeLanguageModel } = {}) {
171
+ /** Each name's session, and the setup it was made with. @type {Map<string, {setup: string, session: ModelSession}>} */
172
+ const sessions = new Map();
173
+ /** The questions being answered, to stop one. @type {Map<string, AbortController>} */
174
+ const asking = new Map();
175
+
176
+ /** @param {HostRequest} request @returns {ModelSession} */
177
+ const sessionFor = ({ name, options = {}, initialPrompts = [] }) => {
178
+ const setup = JSON.stringify([options, initialPrompts]);
179
+ const kept = sessions.get(name);
180
+ if (kept?.setup === setup) return kept.session;
181
+ kept?.session.release();
182
+ const session = createModelSession({ model, options, initialPrompts });
183
+ sessions.set(name, { setup, session });
184
+ return session;
185
+ };
186
+
187
+ /** @param {HostRequest} request @returns {Promise<unknown>} */
188
+ const answer = async (request) => {
189
+ switch (request.method) {
190
+ case 'availability':
191
+ return sessionFor(request).availability();
192
+ case 'ask': {
193
+ const stop = new AbortController();
194
+ const id = String(request.id);
195
+ asking.set(id, stop);
196
+ try {
197
+ return await sessionFor(request).ask(String(request.prompt ?? ''), /** @type {AskOptions} */ ({
198
+ timeoutMs: Number(request.timeoutMs) || 0,
199
+ signal: stop.signal,
200
+ ...(request.responseConstraint && { responseConstraint: request.responseConstraint }),
201
+ }));
202
+ } finally {
203
+ asking.delete(id);
204
+ }
205
+ }
206
+ case 'abort':
207
+ asking.get(String(request.id))?.abort();
208
+ return null;
209
+ case 'release':
210
+ sessions.get(request.name)?.session.release();
211
+ sessions.delete(request.name);
212
+ return null;
213
+ default:
214
+ return null;
215
+ }
216
+ };
217
+
218
+ runtime.onMessage.addListener((message, sender, respond) => {
219
+ const request = /** @type {HostRequest | null | undefined} */ (message);
220
+ if (request?.type !== MODEL_HOST || typeof request.name !== 'string') return false;
221
+ if (sender.id !== runtime.id || sender.tab) return false;
222
+ answer(request).then(respond, () => respond(null));
223
+ return true;
224
+ });
225
+ }
package/src/ai/model.d.ts CHANGED
@@ -9,7 +9,8 @@
9
9
  * only downloads from a user gesture) and which reports progress;
10
10
  * - one base session, with the instructions and examples, is made once and
11
11
  * kept; each question goes to a `clone()` of it, so answers don't leak into
12
- * each other;
12
+ * each other; a base session Chrome has let go since (it can't be cloned)
13
+ * is made again;
13
14
  * - each answer has a time limit: an `AbortSignal` stops the answer once
14
15
  * it's late, and the caller gets a reason instead of a hang;
15
16
  * - the first session after Chrome starts can take ~18 s while the model
package/src/ai/model.js CHANGED
@@ -9,7 +9,8 @@
9
9
  * only downloads from a user gesture) and which reports progress;
10
10
  * - one base session, with the instructions and examples, is made once and
11
11
  * kept; each question goes to a `clone()` of it, so answers don't leak into
12
- * each other;
12
+ * each other; a base session Chrome has let go since (it can't be cloned)
13
+ * is made again;
13
14
  * - each answer has a time limit: an `AbortSignal` stops the answer once
14
15
  * it's late, and the caller gets a reason instead of a hang;
15
16
  * - the first session after Chrome starts can take ~18 s while the model
@@ -265,7 +266,14 @@ export function createModelSession({ model, options, initialPrompts = [] }) {
265
266
  * @returns {Promise<string>} The answer.
266
267
  */
267
268
  async function ask(api, prompt, signal, responseConstraint) {
268
- const session = await (await baseSession(api)).clone({ signal });
269
+ const made = baseSession(api);
270
+ const kept = await made;
271
+ const session = await kept.clone({ signal }).catch(async (error) => {
272
+ if (signal.aborted) throw error;
273
+ // Chrome let the base session go (the model was updated, or its process ended): a new one, once.
274
+ if (base === made) base = null;
275
+ return (await baseSession(api)).clone({ signal });
276
+ });
269
277
  try {
270
278
  return await session.prompt(prompt, { signal, ...(responseConstraint && { responseConstraint }) });
271
279
  } finally {
@@ -5,11 +5,13 @@
5
5
  * `extension/vendor/`; the copy is made from the installed package on
6
6
  * `npm install` (`postinstall`) and is never committed.
7
7
  *
8
- * Each copy is stamped with the package's name and version
9
- * ({@link VENDOR_STAMP}), so {@link checkVendored} can say when a copy no
10
- * longer matches what's installed: `npm install some-package` installs
11
- * without running the extension's own `postinstall`, and the copy goes stale
12
- * without a word.
8
+ * Each copy is stamped with the package's name and version, what was copied
9
+ * and a hash of it ({@link VENDOR_STAMP}), so {@link checkVendored} can say
10
+ * when a copy no longer matches what's installed: `npm install some-package`
11
+ * installs without running the extension's own `postinstall`, and the copy
12
+ * goes stale without a word; so does a copy edited by hand, or a package
13
+ * whose files changed under the same version (a library copied into
14
+ * `node_modules` to try it before it's published).
13
15
  *
14
16
  * An extension with a bundler imports the package instead and copies only
15
17
  * the stylesheet ({@link STYLESHEET}).
@@ -31,6 +33,14 @@ export type VendorStamp = {
31
33
  * Its version when copied.
32
34
  */
33
35
  version: string;
36
+ /**
37
+ * The paths copied, as {@link vendorPackage} took them (the kit's own copy has none).
38
+ */
39
+ files?: string[];
40
+ /**
41
+ * A hash of every file copied ({@link treeHash}); a copy from before 1.11 has none.
42
+ */
43
+ hash?: string;
34
44
  };
35
45
  /**
36
46
  * Where a vendored copy came from.
@@ -38,6 +48,8 @@ export type VendorStamp = {
38
48
  * @typedef {object} VendorStamp
39
49
  * @property {string} package The package's name.
40
50
  * @property {string} version Its version when copied.
51
+ * @property {string[]} [files] The paths copied, as {@link vendorPackage} took them (the kit's own copy has none).
52
+ * @property {string} [hash] A hash of every file copied ({@link treeHash}); a copy from before 1.11 has none.
41
53
  */
42
54
  /**
43
55
  * The folder of an installed package, found as Node finds it (the nearest
@@ -55,6 +67,8 @@ export declare function packageDir(name: string, from?: string): Promise<string>
55
67
  *
56
68
  * @param {string} dest - The folder to fill, e.g. `extension/vendor/extension-kit`. Removed first.
57
69
  * @returns {Promise<string[]>} The entries copied, relative to `dest`.
70
+ * @throws {Error} When `dest` holds an extension or a project (`manifest.json`, `package.json`, `.git`).
71
+ *
58
72
  * @example
59
73
  * await vendorKit('extension/vendor/extension-kit');
60
74
  * // extension/vendor/extension-kit/index.js, …/styles/chrome-ui.css, …, vendored.json
@@ -71,7 +85,9 @@ export declare function vendorKit(dest: string): Promise<string[]>;
71
85
  * @param {string} dest - The folder to fill, e.g. `extension/vendor/clean-link`. Removed first.
72
86
  * @param {{from?: string}} [options] - `from`: where to look for `node_modules` from. Default the current folder.
73
87
  * @returns {Promise<string[]>} The files and folders written, relative to `dest`.
74
- * @throws {Error} When the package isn't installed, or a path isn't in it.
88
+ * @throws {Error} When the package isn't installed, a path isn't in it, two paths would land in
89
+ * the same place (`dist/index.js` and `rules/index.js`), or `dest` holds an extension or a project.
90
+ *
75
91
  * @example
76
92
  * await vendorPackage('@duckduckgo/autoconsent', ['dist/autoconsent.esm.js', 'rules/compact-rules.json', 'LICENSE'], 'extension/vendor/autoconsent');
77
93
  * // or, from postinstall: extension-kit vendor extension/vendor/clean-link @arraypress/clean-link src/
@@ -82,11 +98,16 @@ export declare function vendorPackage(name: string, files: readonly string[], de
82
98
  /**
83
99
  * Every vendored copy under `dir` (a folder with a {@link VENDOR_STAMP},
84
100
  * searched to any depth) that no longer matches the package installed now:
85
- * copied from another version, or from a package that's gone.
101
+ * copied from another version, from a package that's gone, edited since (a
102
+ * file a script wrote beside the copied ones afterwards doesn't count), or
103
+ * from files the installed package no longer has as they were. A folder with
104
+ * no vendored copy at all (a mistyped path, `postinstall` never run) is
105
+ * reported too.
86
106
  *
87
107
  * @param {string} dir - The vendor folder, e.g. `extension/vendor`.
88
108
  * @param {{from?: string}} [options] - `from`: where to look for `node_modules` from. Default the current folder.
89
- * @returns {Promise<string[]>} One line per stale copy; empty when all match (or there are none).
109
+ * @returns {Promise<string[]>} One line per stale copy; empty when all match.
110
+ *
90
111
  * @example
91
112
  * const stale = await checkVendored('extension/vendor');
92
113
  * // ['extension/vendor/extension-kit is @arraypress/extension-kit 1.2.0; 1.3.0 is installed']
@@ -5,11 +5,13 @@
5
5
  * `extension/vendor/`; the copy is made from the installed package on
6
6
  * `npm install` (`postinstall`) and is never committed.
7
7
  *
8
- * Each copy is stamped with the package's name and version
9
- * ({@link VENDOR_STAMP}), so {@link checkVendored} can say when a copy no
10
- * longer matches what's installed: `npm install some-package` installs
11
- * without running the extension's own `postinstall`, and the copy goes stale
12
- * without a word.
8
+ * Each copy is stamped with the package's name and version, what was copied
9
+ * and a hash of it ({@link VENDOR_STAMP}), so {@link checkVendored} can say
10
+ * when a copy no longer matches what's installed: `npm install some-package`
11
+ * installs without running the extension's own `postinstall`, and the copy
12
+ * goes stale without a word; so does a copy edited by hand, or a package
13
+ * whose files changed under the same version (a library copied into
14
+ * `node_modules` to try it before it's published).
13
15
  *
14
16
  * An extension with a bundler imports the package instead and copies only
15
17
  * the stylesheet ({@link STYLESHEET}).
@@ -17,7 +19,8 @@
17
19
  * @module build/vendor
18
20
  */
19
21
 
20
- import { access, cp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
22
+ import { createHash } from 'node:crypto';
23
+ import { access, copyFile, mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
21
24
  import { fileURLToPath } from 'node:url';
22
25
  import path from 'node:path';
23
26
 
@@ -42,6 +45,8 @@ export const VENDOR_STAMP = 'vendored.json';
42
45
  * @typedef {object} VendorStamp
43
46
  * @property {string} package The package's name.
44
47
  * @property {string} version Its version when copied.
48
+ * @property {string[]} [files] The paths copied, as {@link vendorPackage} took them (the kit's own copy has none).
49
+ * @property {string} [hash] A hash of every file copied ({@link treeHash}); a copy from before 1.11 has none.
45
50
  */
46
51
 
47
52
  /**
@@ -73,14 +78,90 @@ async function versionOf(dir) {
73
78
  }
74
79
 
75
80
  /**
76
- * Write a folder's {@link VENDOR_STAMP}.
81
+ * Every file under a file or folder.
77
82
  *
78
- * @param {string} dest - The vendored folder.
79
- * @param {VendorStamp} source - Where the copy came from.
83
+ * @param {string} source - A file or a folder.
84
+ * @returns {Promise<string[]>} Paths from `source`, forward slashes ('' for a file itself).
85
+ */
86
+ async function filesOf(source) {
87
+ if (!(await stat(source)).isDirectory()) return [''];
88
+ const entries = await readdir(source, { recursive: true, withFileTypes: true });
89
+ return entries.filter((entry) => entry.isFile()).map((entry) => path.relative(source, path.join(entry.parentPath, entry.name)).split(path.sep).join('/'));
90
+ }
91
+
92
+ /**
93
+ * What a copy holds: each file's place in the vendored folder and the file it comes from. A
94
+ * path lands under its own name (`dist/lib.esm.js` → `lib.esm.js`); a path ending in `/` has its
95
+ * contents copied instead (`src/` → `index.js`, …).
96
+ *
97
+ * @param {string} dir - The folder the paths are in (a package's, or the kit's `src/`).
98
+ * @param {readonly string[]} files - The paths.
99
+ * @returns {Promise<Map<string, string>>} Place in the copy (forward slashes) → source file.
100
+ * @throws {Error} When a path isn't there, or two files would land in the same place.
101
+ */
102
+ async function copyPlan(dir, files) {
103
+ /** @type {Map<string, string>} */
104
+ const plan = new Map();
105
+ for (const file of files) {
106
+ const source = path.join(dir, file);
107
+ const under = file.endsWith('/') ? '' : path.basename(file);
108
+ for (const inner of await filesOf(source)) {
109
+ const place = [under, inner].filter(Boolean).join('/');
110
+ const taken = plan.get(place);
111
+ if (taken) throw new Error(`${path.relative(dir, taken)} and ${path.relative(dir, path.join(source, inner))} would both be copied to ${place}`);
112
+ plan.set(place, path.join(source, inner));
113
+ }
114
+ }
115
+ return plan;
116
+ }
117
+
118
+ /**
119
+ * A hash of a set of files: their places and contents, in order of place.
120
+ *
121
+ * @param {Iterable<[string, string]>} files - Place (forward slashes) → the file to read.
122
+ * @returns {Promise<string>} SHA-256, hex.
123
+ */
124
+ async function treeHash(files) {
125
+ const hash = createHash('sha256');
126
+ for (const [place, file] of [...files].sort(([a], [b]) => (a < b ? -1 : 1))) {
127
+ hash.update(`${place}\0`).update(await readFile(file)).update('\0');
128
+ }
129
+ return hash.digest('hex');
130
+ }
131
+
132
+ /**
133
+ * Whether a folder is somewhere a copy mustn't go: it would be deleted first, and it holds an
134
+ * extension or a project (`manifest.json`, `package.json`, a `.git`) rather than an older copy.
135
+ *
136
+ * @param {string} dest - The folder to fill.
137
+ * @returns {Promise<void>}
138
+ * @throws {Error} When it does.
139
+ */
140
+ async function refuseSource(dest) {
141
+ for (const sign of ['manifest.json', 'package.json', '.git']) {
142
+ if (await access(path.join(dest, sign)).then(() => true, () => false)) {
143
+ throw new Error(`${dest} holds a ${sign}: it isn't a vendor folder, and copying into it would delete it first`);
144
+ }
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Replace `dest` with the files of a plan, stamped.
150
+ *
151
+ * @param {string} dest - The vendored folder. Removed first.
152
+ * @param {Map<string, string>} plan - From {@link copyPlan}.
153
+ * @param {Omit<VendorStamp, 'hash'>} source - Where the copy comes from.
80
154
  * @returns {Promise<void>}
81
155
  */
82
- async function stamp(dest, source) {
83
- await writeFile(path.join(dest, VENDOR_STAMP), `${JSON.stringify(source, null, 2)}\n`);
156
+ async function copyStamped(dest, plan, source) {
157
+ await refuseSource(dest);
158
+ await rm(dest, { recursive: true, force: true });
159
+ await mkdir(dest, { recursive: true });
160
+ for (const [place, file] of plan) {
161
+ await mkdir(path.dirname(path.join(dest, place)), { recursive: true });
162
+ await copyFile(file, path.join(dest, place));
163
+ }
164
+ await writeFile(path.join(dest, VENDOR_STAMP), `${JSON.stringify({ ...source, hash: await treeHash(plan) }, null, 2)}\n`);
84
165
  }
85
166
 
86
167
  /**
@@ -88,17 +169,14 @@ async function stamp(dest, source) {
88
169
  *
89
170
  * @param {string} dest - The folder to fill, e.g. `extension/vendor/extension-kit`. Removed first.
90
171
  * @returns {Promise<string[]>} The entries copied, relative to `dest`.
172
+ * @throws {Error} When `dest` holds an extension or a project (`manifest.json`, `package.json`, `.git`).
173
+ *
91
174
  * @example
92
175
  * await vendorKit('extension/vendor/extension-kit');
93
176
  * // extension/vendor/extension-kit/index.js, …/styles/chrome-ui.css, …, vendored.json
94
177
  */
95
178
  export async function vendorKit(dest) {
96
- await rm(dest, { recursive: true, force: true });
97
- await mkdir(dest, { recursive: true });
98
- for (const entry of RUNTIME_ENTRIES) {
99
- await cp(path.join(SOURCE, entry), path.join(dest, entry), { recursive: true });
100
- }
101
- await stamp(dest, { package: KIT, version: await versionOf(path.dirname(SOURCE)) });
179
+ await copyStamped(dest, await copyPlan(SOURCE, RUNTIME_ENTRIES), { package: KIT, version: await versionOf(path.dirname(SOURCE)) });
102
180
  return [...RUNTIME_ENTRIES];
103
181
  }
104
182
 
@@ -113,54 +191,72 @@ export async function vendorKit(dest) {
113
191
  * @param {string} dest - The folder to fill, e.g. `extension/vendor/clean-link`. Removed first.
114
192
  * @param {{from?: string}} [options] - `from`: where to look for `node_modules` from. Default the current folder.
115
193
  * @returns {Promise<string[]>} The files and folders written, relative to `dest`.
116
- * @throws {Error} When the package isn't installed, or a path isn't in it.
194
+ * @throws {Error} When the package isn't installed, a path isn't in it, two paths would land in
195
+ * the same place (`dist/index.js` and `rules/index.js`), or `dest` holds an extension or a project.
196
+ *
117
197
  * @example
118
198
  * await vendorPackage('@duckduckgo/autoconsent', ['dist/autoconsent.esm.js', 'rules/compact-rules.json', 'LICENSE'], 'extension/vendor/autoconsent');
119
199
  * // or, from postinstall: extension-kit vendor extension/vendor/clean-link @arraypress/clean-link src/
120
200
  */
121
201
  export async function vendorPackage(name, files, dest, { from } = {}) {
122
202
  const dir = await packageDir(name, from);
123
- await rm(dest, { recursive: true, force: true });
124
- await mkdir(dest, { recursive: true });
125
- /** @type {string[]} */
126
- const written = [];
127
- for (const file of files) {
128
- const source = path.join(dir, file);
129
- if (file.endsWith('/')) {
130
- await cp(source, dest, { recursive: true });
131
- written.push(...await readdir(source));
132
- } else {
133
- await cp(source, path.join(dest, path.basename(file)), { recursive: true });
134
- written.push(path.basename(file));
135
- }
203
+ const plan = await copyPlan(dir, files);
204
+ await copyStamped(dest, plan, { package: name, version: await versionOf(dir), files: [...files] });
205
+ return [...new Set([...plan.keys()].map((place) => place.split('/')[0]))];
206
+ }
207
+
208
+ /**
209
+ * Why a stamped copy no longer matches the package installed now, if it doesn't.
210
+ *
211
+ * @param {string} folder - The vendored folder.
212
+ * @param {VendorStamp} copied - Its stamp.
213
+ * @param {string | undefined} from - Where to look for `node_modules` from.
214
+ * @returns {Promise<string | null>} The line to print, or null when it matches.
215
+ */
216
+ async function staleness(folder, copied, from) {
217
+ const dir = await packageDir(copied.package, from).catch(() => null);
218
+ if (dir === null) return `${folder} is ${copied.package} ${copied.version}, which isn't installed`;
219
+ const installed = await versionOf(dir);
220
+ if (installed !== copied.version) return `${folder} is ${copied.package} ${copied.version}; ${installed} is installed`;
221
+ if (!copied.hash) return null;
222
+ const plan = await (copied.files ? copyPlan(dir, copied.files) : copyPlan(path.join(dir, 'src'), RUNTIME_ENTRIES)).catch(() => null);
223
+ if (plan === null || await treeHash(plan) !== copied.hash) {
224
+ return `${folder} is ${copied.package} ${copied.version}, but the installed ${copied.version}'s files are different`;
136
225
  }
137
- await stamp(dest, { package: name, version: await versionOf(dir) });
138
- return written;
226
+ // The copied files as they are now; files a script wrote beside them afterwards aren't the package's.
227
+ const here = await treeHash([...plan.keys()].map((place) => [place, path.join(folder, place)])).catch(() => null);
228
+ if (here !== copied.hash) return `${folder} has changed since it was copied from ${copied.package} ${copied.version}`;
229
+ return null;
139
230
  }
140
231
 
141
232
  /**
142
233
  * Every vendored copy under `dir` (a folder with a {@link VENDOR_STAMP},
143
234
  * searched to any depth) that no longer matches the package installed now:
144
- * copied from another version, or from a package that's gone.
235
+ * copied from another version, from a package that's gone, edited since (a
236
+ * file a script wrote beside the copied ones afterwards doesn't count), or
237
+ * from files the installed package no longer has as they were. A folder with
238
+ * no vendored copy at all (a mistyped path, `postinstall` never run) is
239
+ * reported too.
145
240
  *
146
241
  * @param {string} dir - The vendor folder, e.g. `extension/vendor`.
147
242
  * @param {{from?: string}} [options] - `from`: where to look for `node_modules` from. Default the current folder.
148
- * @returns {Promise<string[]>} One line per stale copy; empty when all match (or there are none).
243
+ * @returns {Promise<string[]>} One line per stale copy; empty when all match.
244
+ *
149
245
  * @example
150
246
  * const stale = await checkVendored('extension/vendor');
151
247
  * // ['extension/vendor/extension-kit is @arraypress/extension-kit 1.2.0; 1.3.0 is installed']
152
248
  */
153
249
  export async function checkVendored(dir, { from } = {}) {
154
250
  const entries = await readdir(dir, { recursive: true }).catch(() => /** @type {string[]} */ ([]));
251
+ const stamps = entries.filter((file) => path.basename(file) === VENDOR_STAMP).sort();
252
+ if (!stamps.length) return [`${dir} has no vendored copies (no ${VENDOR_STAMP} in it)`];
155
253
  /** @type {string[]} */
156
254
  const stale = [];
157
- for (const entry of entries.filter((file) => path.basename(file) === VENDOR_STAMP).sort()) {
158
- const folder = path.join(dir, path.dirname(entry));
255
+ for (const entry of stamps) {
159
256
  /** @type {VendorStamp} */
160
257
  const copied = JSON.parse(await readFile(path.join(dir, entry), 'utf8'));
161
- const installed = await packageDir(copied.package, from).then(versionOf, () => null);
162
- if (installed === null) stale.push(`${folder} is ${copied.package} ${copied.version}, which isn't installed`);
163
- else if (installed !== copied.version) stale.push(`${folder} is ${copied.package} ${copied.version}; ${installed} is installed`);
258
+ const line = await staleness(path.join(dir, path.dirname(entry)), copied, from);
259
+ if (line) stale.push(line);
164
260
  }
165
261
  return stale;
166
262
  }
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * “Show on page”: boxes in Chrome's blue (DevTools' highlight) around a set of
3
3
  * elements, kept on them as the page scrolls or resizes, until cleared (the
4
- * extension's button again, another set, or Esc). The first can be scrolled
5
- * into view. The boxes go in the extension's own overlay (`pageOverlay`), so
4
+ * extension's button again, another set, Esc, or the page being left) and
5
+ * `onClear` is told which, so a panel's pressed button can follow an Esc on
6
+ * the page. The first can be scrolled into view. The boxes go in the extension's own overlay (`pageOverlay`), so
6
7
  * the page's styles can't touch them.
7
8
  *
8
9
  * @module content/highlight
@@ -21,10 +22,11 @@ export type Highlighter = {
21
22
  reveal?: boolean;
22
23
  }) => number;
23
24
  /**
24
- * Take the boxes away.
25
+ * Take the boxes away (`onClear('asked')`).
25
26
  */
26
27
  clear: () => void;
27
28
  };
29
+ export type ClearReason = 'escape' | 'replaced' | 'asked' | 'pagehide';
28
30
  /**
29
31
  * A page's highlight: one set of elements boxed at a time.
30
32
  *
@@ -32,7 +34,13 @@ export type Highlighter = {
32
34
  * @property {(elements: Element[], options?: {reveal?: boolean}) => number} show Box elements (the
33
35
  * ones still in the page, the first {@link MAX_BOXES}), clearing any boxed before; `reveal`
34
36
  * scrolls the first into view. Returns how many are boxed.
35
- * @property {() => void} clear Take the boxes away.
37
+ * @property {() => void} clear Take the boxes away (`onClear('asked')`).
38
+ */
39
+ /**
40
+ * Why the boxes went: Esc on the page, another set shown in their place, `clear()` called, or
41
+ * the page left (`pagehide`: a page kept for Back would come back with stale boxes).
42
+ *
43
+ * @typedef {'escape' | 'replaced' | 'asked' | 'pagehide'} ClearReason
36
44
  */
37
45
  /**
38
46
  * A highlighter for a page.
@@ -40,7 +48,8 @@ export type Highlighter = {
40
48
  * @param {Document} doc - The page.
41
49
  * @param {object} options
42
50
  * @param {() => ParentNode} options.root - Where boxes go: the overlay's shadow root, made when first needed.
43
- * @param {() => void} [options.onClear] - After the boxes go (take an empty overlay away).
51
+ * @param {(reason: ClearReason) => void} [options.onClear] - After the boxes go, and why (take an empty
52
+ * overlay away; tell the panel when it's `'escape'` or `'pagehide'`, which it didn't ask for).
44
53
  * @param {number} [options.max] - The most elements boxed. Default {@link MAX_BOXES}.
45
54
  * @returns {Highlighter} It.
46
55
  * @example
@@ -49,6 +58,6 @@ export type Highlighter = {
49
58
  */
50
59
  export declare function createHighlighter(doc: Document, { root, onClear, max }: {
51
60
  root: () => ParentNode;
52
- onClear?: () => void;
61
+ onClear?: (reason: ClearReason) => void;
53
62
  max?: number;
54
63
  }): Highlighter;