@arraypress/extension-kit 1.6.0 → 1.7.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.
@@ -0,0 +1,312 @@
1
+ /**
2
+ * Chrome's on-device model (Gemini Nano, through the Prompt API), the way
3
+ * every extension here uses it, following
4
+ * https://developer.chrome.com/docs/ai/prompt-api:
5
+ *
6
+ * - `availability()` is asked with the options `create()` gets, and only an
7
+ * 'available' model is used: nothing here downloads it, except
8
+ * {@link downloadModel}, which a page calls from the person's click (Chrome
9
+ * only downloads from a user gesture) and which reports progress;
10
+ * - one base session, with the instructions and examples, is made once and
11
+ * kept; each question goes to a `clone()` of it, so answers don't leak into
12
+ * each other;
13
+ * - each answer has a time limit: an `AbortSignal` stops the answer once
14
+ * it's late, and the caller gets a reason instead of a hang;
15
+ * - the first session after Chrome starts can take ~18 s while the model
16
+ * loads (a cold start, M3 Max). A question with a shorter limit gets
17
+ * 'late', and the session goes on loading for the next one. Nothing loads
18
+ * it ahead of time: that would load the model on every start for work that
19
+ * may never come.
20
+ *
21
+ * Nothing here touches `chrome.*` or the global itself: the Prompt API comes
22
+ * in as `model` (read when used, since Chrome may add it after start-up), so
23
+ * tests hand it the kit's fake (`fakeLanguageModel`, `/testing`) at exactly
24
+ * this edge. TypeScript's DOM library doesn't declare the Prompt API yet, so
25
+ * the parts used are typed here.
26
+ *
27
+ * @module ai/model
28
+ */
29
+
30
+ import { within } from '../time/time.js';
31
+
32
+ /**
33
+ * The model's answer to `availability()`: 'available', 'downloadable',
34
+ * 'downloading' or 'unavailable'; and, from {@link modelAvailability},
35
+ * 'missing' when this Chrome has no Prompt API.
36
+ *
37
+ * @typedef {'available' | 'downloadable' | 'downloading' | 'unavailable' | 'missing'} Availability
38
+ */
39
+
40
+ /**
41
+ * @typedef {object} PromptOptions
42
+ * @property {AbortSignal} [signal] Stops the answer.
43
+ * @property {object} [responseConstraint] A JSON Schema (or RegExp) the answer must follow.
44
+ */
45
+
46
+ /**
47
+ * @typedef {object} LanguageModelSession
48
+ * @property {(prompt: string, options?: PromptOptions) => Promise<string>} prompt The whole answer.
49
+ * @property {(options?: {signal?: AbortSignal}) => Promise<LanguageModelSession>} clone A copy with the same history.
50
+ * @property {() => void} destroy Let it go.
51
+ */
52
+
53
+ /**
54
+ * @typedef {{role: 'system' | 'user' | 'assistant', content: string}} InitialPrompt
55
+ */
56
+
57
+ /**
58
+ * @typedef {object} LanguageModelOptions
59
+ * @property {{type: string, languages: string[]}[]} [expectedInputs] What it's given.
60
+ * @property {{type: string, languages: string[]}[]} [expectedOutputs] What it answers in.
61
+ * @property {InitialPrompt[]} [initialPrompts] Its instructions and examples.
62
+ * @property {(monitor: EventTarget) => void} [monitor] Hears `downloadprogress` (`loaded`, 0–1) while the model downloads.
63
+ * @property {AbortSignal} [signal] Stops creating it.
64
+ */
65
+
66
+ /**
67
+ * @typedef {object} LanguageModelStatic
68
+ * @property {(options?: LanguageModelOptions) => Promise<Availability>} availability Whether it can run here.
69
+ * @property {(options?: LanguageModelOptions) => Promise<LanguageModelSession>} create A session (downloads the model first, from a click).
70
+ */
71
+
72
+ /**
73
+ * Chrome's `LanguageModel`, or undefined when this Chrome has none.
74
+ *
75
+ * @param {object} [scope] - Where to look. Default `globalThis` (a page, or the service worker).
76
+ * @returns {LanguageModelStatic | undefined} The Prompt API.
77
+ * @example
78
+ * const sessions = createModelSession({ model: chromeLanguageModel, options: ENGLISH });
79
+ */
80
+ export function chromeLanguageModel(scope = globalThis) {
81
+ return /** @type {{LanguageModel?: LanguageModelStatic}} */ (scope).LanguageModel;
82
+ }
83
+
84
+ /**
85
+ * The model's state, never throwing: 'missing' without a Prompt API, and
86
+ * 'unavailable' when asking fails.
87
+ *
88
+ * @param {LanguageModelStatic | undefined} model - The Prompt API.
89
+ * @param {LanguageModelOptions} options - The options sessions are created with.
90
+ * @returns {Promise<Availability>} Its state.
91
+ * @example
92
+ * await modelAvailability(chromeLanguageModel(), ENGLISH); // 'available'
93
+ */
94
+ export async function modelAvailability(model, options) {
95
+ if (!model) return 'missing';
96
+ try {
97
+ return await model.availability(options);
98
+ } catch {
99
+ return 'unavailable';
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Download the model, from the person's click: `create()` with a monitor,
105
+ * the session let go once made. Call it first in the click's handler, with
106
+ * nothing awaited before it, or Chrome refuses (no user activation).
107
+ *
108
+ * @param {LanguageModelStatic | undefined} model - The Prompt API.
109
+ * @param {LanguageModelOptions} options - The options sessions are created with.
110
+ * @param {{onProgress?: (loaded: number) => void, signal?: AbortSignal}} [handlers] - `onProgress`: how much is
111
+ * downloaded, 0–1, as Chrome reports it; `signal`: stops it.
112
+ * @returns {Promise<Availability>} The state afterwards ('available' once it's here).
113
+ * @throws {Error} When there's no Prompt API, or Chrome couldn't download it.
114
+ * @example
115
+ * button.addEventListener('click', () => {
116
+ * downloadModel(chromeLanguageModel(), ENGLISH, { onProgress: (loaded) => { bar.value = loaded; } })
117
+ * .then(show, () => show('downloadable', 'The download didn’t finish. Try again.'));
118
+ * });
119
+ */
120
+ export async function downloadModel(model, options, { onProgress, signal } = {}) {
121
+ if (!model) throw new Error('This Chrome has no Prompt API');
122
+ const session = await model.create({
123
+ ...options,
124
+ ...(signal && { signal }),
125
+ monitor: (monitor) => monitor.addEventListener('downloadprogress', (event) => {
126
+ onProgress?.(Number(/** @type {ProgressEvent} */ (event).loaded) || 0);
127
+ }),
128
+ });
129
+ session.destroy();
130
+ return modelAvailability(model, options);
131
+ }
132
+
133
+ /**
134
+ * The settings page's words for a state: standard message keys (the
135
+ * extension's `_locales` names them alike), as `SITE_LIST_MESSAGES` does
136
+ * for a site list.
137
+ */
138
+ export const MODEL_MESSAGES = Object.freeze({
139
+ /** “Chrome’s built-in AI”: the row's name. */
140
+ name: 'optionsModel',
141
+ /** “Checking…” before the state is known. */
142
+ checking: 'optionsModelChecking',
143
+ /** “Ready”. */
144
+ ready: 'optionsModelReady',
145
+ /** “Needs download”. */
146
+ needsDownload: 'optionsModelNeedsDownload',
147
+ /** “Chrome downloads its model once and shares it with every site and extension that uses it”. */
148
+ downloadHelp: 'optionsModelDownloadHelp',
149
+ /** “Download”: the button. */
150
+ download: 'optionsModelDownload',
151
+ /** “Downloading… $PERCENT$%” (content `$1`). */
152
+ downloading: 'optionsModelDownloading',
153
+ /** “The download didn’t finish. Try again.” */
154
+ downloadFailed: 'optionsModelDownloadFailed',
155
+ /** “Not supported on this computer”. */
156
+ unsupported: 'optionsModelUnsupported',
157
+ /** What a computer needs to run it. */
158
+ requirements: 'optionsModelRequirements',
159
+ /** “This Chrome doesn’t have it”. */
160
+ missing: 'optionsModelMissing',
161
+ /** What to do about that, and that the extension works without it. */
162
+ missingHelp: 'optionsModelMissingHelp',
163
+ });
164
+
165
+ /**
166
+ * The model's state, as a settings page shows it.
167
+ *
168
+ * @typedef {object} ModelStatus
169
+ * @property {'ready' | 'pending' | 'off'} state Ready to use, can be downloaded, or can't run here.
170
+ * @property {string} status The status's message key ({@link MODEL_MESSAGES}).
171
+ * @property {string} help The help's message key ('' for none).
172
+ * @property {boolean} canDownload Whether to offer Download.
173
+ */
174
+
175
+ /**
176
+ * Describe an availability for a settings page. Pure.
177
+ *
178
+ * @param {string} availability - 'available', 'downloadable', 'downloading', 'unavailable' or 'missing'.
179
+ * @returns {ModelStatus} How to show it.
180
+ * @example
181
+ * describeModel('downloadable'); // { state: 'pending', status: 'optionsModelNeedsDownload', help: 'optionsModelDownloadHelp', canDownload: true }
182
+ */
183
+ export function describeModel(availability) {
184
+ switch (availability) {
185
+ case 'available':
186
+ return { state: 'ready', status: MODEL_MESSAGES.ready, help: '', canDownload: false };
187
+ case 'downloadable':
188
+ case 'downloading':
189
+ return { state: 'pending', status: MODEL_MESSAGES.needsDownload, help: MODEL_MESSAGES.downloadHelp, canDownload: true };
190
+ case 'missing':
191
+ return { state: 'off', status: MODEL_MESSAGES.missing, help: MODEL_MESSAGES.missingHelp, canDownload: false };
192
+ default:
193
+ return { state: 'off', status: MODEL_MESSAGES.unsupported, help: MODEL_MESSAGES.requirements, canDownload: false };
194
+ }
195
+ }
196
+
197
+ /**
198
+ * What a question got: the answer, or why there's none: the model isn't
199
+ * on this computer ('unavailable'), the time ran out first ('late', a cold
200
+ * start among them), the caller's signal stopped it ('aborted'), or the model
201
+ * failed ('failed').
202
+ *
203
+ * @typedef {{text: string, reason: 'answered'} | {text: null, reason: 'unavailable' | 'late' | 'aborted' | 'failed'}} ModelAnswer
204
+ */
205
+
206
+ /**
207
+ * @typedef {object} AskOptions
208
+ * @property {number} timeoutMs The most the answer may take, from the moment it's asked: the base
209
+ * session (if it must be made) and the answer.
210
+ * @property {AbortSignal} [signal] Stops it sooner (the person cancelled).
211
+ * @property {object} [responseConstraint] A JSON Schema the answer must follow.
212
+ */
213
+
214
+ /**
215
+ * @typedef {object} ModelSession
216
+ * @property {() => Promise<Availability>} availability The model's state, or 'missing'.
217
+ * @property {(prompt: string, options: AskOptions) => Promise<ModelAnswer>} ask Ask a copy of the base session.
218
+ * @property {() => void} release Let the base session go (the next question makes it again).
219
+ */
220
+
221
+ /**
222
+ * A base session over a Prompt API, made when first needed and kept, each
223
+ * question asked of a `clone()` within its time limit.
224
+ *
225
+ * @param {{model: () => LanguageModelStatic | undefined, options: LanguageModelOptions, initialPrompts?: InitialPrompt[]}} setup -
226
+ * `model`: the Prompt API, read when used; `options`: what sessions are made (and availability asked)
227
+ * with; `initialPrompts`: the instructions and examples every question starts from.
228
+ * @returns {ModelSession} The session.
229
+ * @example
230
+ * const sessions = createModelSession({
231
+ * model: chromeLanguageModel,
232
+ * options: { expectedInputs: [{ type: 'text', languages: ['en'] }], expectedOutputs: [{ type: 'text', languages: ['en'] }] },
233
+ * initialPrompts: [{ role: 'system', content: 'You name downloaded files.' }],
234
+ * });
235
+ * const { text } = await sessions.ask('Site: acme.com', { timeoutMs: 2500 }); // null when late
236
+ */
237
+ export function createModelSession({ model, options, initialPrompts = [] }) {
238
+ /** @type {Promise<LanguageModelSession> | null} */
239
+ let base = null;
240
+
241
+ /**
242
+ * The base session, made once; a failed one isn't kept, so the next question tries again.
243
+ *
244
+ * @param {LanguageModelStatic} api - The Prompt API, ready.
245
+ * @returns {Promise<LanguageModelSession>} The session.
246
+ */
247
+ function baseSession(api) {
248
+ if (!base) {
249
+ const made = api.create({ ...options, ...(initialPrompts.length && { initialPrompts }) });
250
+ base = made;
251
+ made.catch(() => {
252
+ if (base === made) base = null;
253
+ });
254
+ }
255
+ return base;
256
+ }
257
+
258
+ /**
259
+ * Ask a copy of the base session; the copy is let go after.
260
+ *
261
+ * @param {LanguageModelStatic} api - The Prompt API, ready.
262
+ * @param {string} prompt - The question.
263
+ * @param {AbortSignal} signal - Stops it once it's late or the caller stops it.
264
+ * @param {object | undefined} responseConstraint - The answer's shape.
265
+ * @returns {Promise<string>} The answer.
266
+ */
267
+ async function ask(api, prompt, signal, responseConstraint) {
268
+ const session = await (await baseSession(api)).clone({ signal });
269
+ try {
270
+ return await session.prompt(prompt, { signal, ...(responseConstraint && { responseConstraint }) });
271
+ } finally {
272
+ session.destroy();
273
+ }
274
+ }
275
+
276
+ return {
277
+ availability: () => modelAvailability(model(), options),
278
+ async ask(prompt, { timeoutMs, signal, responseConstraint }) {
279
+ const started = Date.now();
280
+ if (signal?.aborted) return { text: null, reason: 'aborted' };
281
+ const api = model();
282
+ const state = await within(modelAvailability(api, options), timeoutMs, /** @type {Availability | 'late'} */ ('late'));
283
+ if (state === 'late') return { text: null, reason: 'late' };
284
+ if (state !== 'available' || !api) return { text: null, reason: 'unavailable' };
285
+ const stop = new AbortController();
286
+ const onAbort = () => stop.abort();
287
+ signal?.addEventListener('abort', onAbort, { once: true });
288
+ try {
289
+ // A string is the answer; false, it failed; null, it's late.
290
+ const outcome = await within(
291
+ ask(api, prompt, stop.signal, responseConstraint).catch(() => /** @type {false} */ (false)),
292
+ Math.max(0, timeoutMs - (Date.now() - started)),
293
+ /** @type {string | false | null} */ (null),
294
+ );
295
+ if (typeof outcome === 'string') return { text: outcome, reason: 'answered' };
296
+ if (signal?.aborted) return { text: null, reason: 'aborted' };
297
+ if (outcome === null) {
298
+ stop.abort();
299
+ return { text: null, reason: 'late' };
300
+ }
301
+ return { text: null, reason: 'failed' };
302
+ } finally {
303
+ signal?.removeEventListener('abort', onAbort);
304
+ }
305
+ },
306
+ release() {
307
+ const held = base;
308
+ base = null;
309
+ held?.then((session) => session.destroy(), () => {});
310
+ },
311
+ };
312
+ }
@@ -28,7 +28,7 @@ const SOURCE = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
28
28
  const KIT = '@arraypress/extension-kit';
29
29
 
30
30
  /** What a vendored copy of the kit holds, relative to `src/`: everything an extension page or worker may load. */
31
- export const RUNTIME_ENTRIES = Object.freeze(['index.js', 'index.d.ts', 'about', 'commands', 'dom', 'scripting', 'settings', 'sites', 'styles', 'time', 'toolbar', 'ui']);
31
+ export const RUNTIME_ENTRIES = Object.freeze(['index.js', 'index.d.ts', 'about', 'ai', 'commands', 'content', 'dom', 'scripting', 'settings', 'sites', 'styles', 'time', 'toolbar', 'ui']);
32
32
 
33
33
  /** The kit's stylesheet, an absolute path in the installed package. */
34
34
  export const STYLESHEET = path.join(SOURCE, 'styles', 'chrome-ui.css');
@@ -0,0 +1,63 @@
1
+ /**
2
+ * A content script's first look at a big page, in slices: the page is cut
3
+ * into blocks of at most `blockChars` characters of text (a long listing's
4
+ * cards, a long article's paragraphs), each handed whole to the caller's
5
+ * work, and the main thread is handed back to the page every `sliceMs`, so
6
+ * a 2,000-item page never freezes. From Better Prices (`content/slices.js`),
7
+ * shared with Topicless.
8
+ *
9
+ * @module content/slices
10
+ */
11
+ /** The most text one block holds (characters), by default. */
12
+ export declare const BLOCK_CHARS = 4000;
13
+ /** How long one slice may run before the page gets the main thread back (ms), by default. Shorter slices cost more layout in all. */
14
+ export declare const SLICE_MS = 40;
15
+ /**
16
+ * The blocks of a part of the page: itself if small enough, else its
17
+ * children's blocks (its own text nodes as blocks of their own).
18
+ *
19
+ * @param {Node} root - The part.
20
+ * @param {number} [blockChars] - The most text a block holds. Default {@link BLOCK_CHARS}.
21
+ * @returns {Node[]} The blocks, in page order.
22
+ * @example
23
+ * blocksOf(document.body).length; // 1 for a short page, one per card for a long listing
24
+ */
25
+ export declare function blocksOf(root: Node, blockChars?: number): Node[];
26
+ export type SliceOptions = {
27
+ /**
28
+ * The most text a block holds. Default {@link BLOCK_CHARS}.
29
+ */
30
+ blockChars?: number;
31
+ /**
32
+ * How long a slice may run. Default {@link SLICE_MS}.
33
+ */
34
+ sliceMs?: number;
35
+ /**
36
+ * Hands the main thread back. Default: a macrotask.
37
+ */
38
+ pause?: () => Promise<void>;
39
+ /**
40
+ * The clock. Default `performance.now`.
41
+ */
42
+ now?: () => number;
43
+ };
44
+ /**
45
+ * @typedef {object} SliceOptions
46
+ * @property {number} [blockChars] The most text a block holds. Default {@link BLOCK_CHARS}.
47
+ * @property {number} [sliceMs] How long a slice may run. Default {@link SLICE_MS}.
48
+ * @property {() => Promise<void>} [pause] Hands the main thread back. Default: a macrotask.
49
+ * @property {() => number} [now] The clock. Default `performance.now`.
50
+ */
51
+ /**
52
+ * Work through a part of the page in slices: `work(block)` on each block
53
+ * still in the page, the main thread handed back whenever a slice has run
54
+ * `sliceMs`.
55
+ *
56
+ * @param {Node} root - The part (the body).
57
+ * @param {(block: Node) => void} work - What to do with one block.
58
+ * @param {SliceOptions} [options] - Block size, slice length, how to pause.
59
+ * @returns {Promise<void>} Once every block is done.
60
+ * @example
61
+ * await inSlices(document.body, (block) => scanner.scan(block));
62
+ */
63
+ export declare function inSlices(root: Node, work: (block: Node) => void, { blockChars, sliceMs, pause, now }?: SliceOptions): Promise<void>;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * A content script's first look at a big page, in slices: the page is cut
3
+ * into blocks of at most `blockChars` characters of text (a long listing's
4
+ * cards, a long article's paragraphs), each handed whole to the caller's
5
+ * work, and the main thread is handed back to the page every `sliceMs`, so
6
+ * a 2,000-item page never freezes. From Better Prices (`content/slices.js`),
7
+ * shared with Topicless.
8
+ *
9
+ * @module content/slices
10
+ */
11
+
12
+ /** The most text one block holds (characters), by default. */
13
+ export const BLOCK_CHARS = 4000;
14
+
15
+ /** How long one slice may run before the page gets the main thread back (ms), by default. Shorter slices cost more layout in all. */
16
+ export const SLICE_MS = 40;
17
+
18
+ /**
19
+ * The blocks of a part of the page: itself if small enough, else its
20
+ * children's blocks (its own text nodes as blocks of their own).
21
+ *
22
+ * @param {Node} root - The part.
23
+ * @param {number} [blockChars] - The most text a block holds. Default {@link BLOCK_CHARS}.
24
+ * @returns {Node[]} The blocks, in page order.
25
+ * @example
26
+ * blocksOf(document.body).length; // 1 for a short page, one per card for a long listing
27
+ */
28
+ export function blocksOf(root, blockChars = BLOCK_CHARS) {
29
+ /** @type {Node[]} */
30
+ const blocks = [];
31
+ /** @param {Node} node */
32
+ const cut = (node) => {
33
+ if (node.nodeType !== 1 || (node.textContent ?? '').length <= blockChars || !node.firstChild) {
34
+ blocks.push(node);
35
+ return;
36
+ }
37
+ for (const child of node.childNodes) cut(child);
38
+ };
39
+ cut(root);
40
+ return blocks;
41
+ }
42
+
43
+ /**
44
+ * Hand the main thread back to the page: a macrotask later.
45
+ *
46
+ * @returns {Promise<void>}
47
+ */
48
+ const nextTask = () => new Promise((resolve) => { setTimeout(resolve, 0); });
49
+
50
+ /**
51
+ * @typedef {object} SliceOptions
52
+ * @property {number} [blockChars] The most text a block holds. Default {@link BLOCK_CHARS}.
53
+ * @property {number} [sliceMs] How long a slice may run. Default {@link SLICE_MS}.
54
+ * @property {() => Promise<void>} [pause] Hands the main thread back. Default: a macrotask.
55
+ * @property {() => number} [now] The clock. Default `performance.now`.
56
+ */
57
+
58
+ /**
59
+ * Work through a part of the page in slices: `work(block)` on each block
60
+ * still in the page, the main thread handed back whenever a slice has run
61
+ * `sliceMs`.
62
+ *
63
+ * @param {Node} root - The part (the body).
64
+ * @param {(block: Node) => void} work - What to do with one block.
65
+ * @param {SliceOptions} [options] - Block size, slice length, how to pause.
66
+ * @returns {Promise<void>} Once every block is done.
67
+ * @example
68
+ * await inSlices(document.body, (block) => scanner.scan(block));
69
+ */
70
+ export async function inSlices(root, work, { blockChars = BLOCK_CHARS, sliceMs = SLICE_MS, pause = nextTask, now = () => performance.now() } = {}) {
71
+ let started = now();
72
+ for (const block of blocksOf(root, blockChars)) {
73
+ if (!block.isConnected) continue;
74
+ work(block);
75
+ if (now() - started > sliceMs) {
76
+ await pause();
77
+ started = now();
78
+ }
79
+ }
80
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * A content script keeping up with a page that changes (a single-page app,
3
+ * a list that loads more as you scroll): a MutationObserver that only
4
+ * collects what changed, and hands it over in one batch a little later,
5
+ * never touching the page itself. The extension's own elements are ignored
6
+ * (`ignore`), so what it adds never sets it off again. From Better Prices
7
+ * (`content/watch.js`), shared with Topicless.
8
+ *
9
+ * @module content/watch
10
+ */
11
+ /** How long after a change the batch waits for more (ms), by default. */
12
+ export declare const SETTLE_MS = 120;
13
+ /** The longest a batch waits while a page keeps changing (ms), by default. */
14
+ export declare const MAX_WAIT_MS = 600;
15
+ export type WatchHandlers = {
16
+ /**
17
+ * The nodes added or changed since the last batch, each once,
18
+ * none inside another.
19
+ */
20
+ changed: (nodes: Node[]) => void;
21
+ /**
22
+ * A node that left (at once, so what the extension added for it
23
+ * can go with it).
24
+ */
25
+ removed?: (node: Node) => void;
26
+ /**
27
+ * Whether a node added or removed is the extension's own.
28
+ */
29
+ ignore?: (node: Node) => boolean;
30
+ /**
31
+ * How long a batch waits for more. Default {@link SETTLE_MS}.
32
+ */
33
+ settleMs?: number;
34
+ /**
35
+ * The longest a batch waits. Default {@link MAX_WAIT_MS}.
36
+ */
37
+ maxWaitMs?: number;
38
+ /**
39
+ * An element whose children changed is handed over again whole
40
+ * when it has at most this many element children (a split price's pieces; a list's new items
41
+ * are handed over themselves). Default 6; 0 never.
42
+ */
43
+ regroupChildren?: number;
44
+ };
45
+ /**
46
+ * @typedef {object} WatchHandlers
47
+ * @property {(nodes: Node[]) => void} changed The nodes added or changed since the last batch, each once,
48
+ * none inside another.
49
+ * @property {(node: Node) => void} [removed] A node that left (at once, so what the extension added for it
50
+ * can go with it).
51
+ * @property {(node: Node) => boolean} [ignore] Whether a node added or removed is the extension's own.
52
+ * @property {number} [settleMs] How long a batch waits for more. Default {@link SETTLE_MS}.
53
+ * @property {number} [maxWaitMs] The longest a batch waits. Default {@link MAX_WAIT_MS}.
54
+ * @property {number} [regroupChildren] An element whose children changed is handed over again whole
55
+ * when it has at most this many element children (a split price's pieces; a list's new items
56
+ * are handed over themselves). Default 6; 0 never.
57
+ */
58
+ /**
59
+ * Watch `root` for changes.
60
+ *
61
+ * @param {Node} root - What to watch (the body, or the document element from `document_start`).
62
+ * @param {WatchHandlers} handlers - What to call, and how long to wait.
63
+ * @returns {() => void} Stops watching.
64
+ * @example
65
+ * const stop = watchChanges(document.body, {
66
+ * changed: (nodes) => nodes.forEach((node) => scanner.scan(node)),
67
+ * removed: (node) => scanner.forget(node),
68
+ * ignore: (node) => node instanceof Element && node.localName === 'better-prices-note',
69
+ * });
70
+ */
71
+ export declare function watchChanges(root: Node, { changed, removed, ignore, settleMs, maxWaitMs, regroupChildren, }: WatchHandlers): () => void;
@@ -0,0 +1,95 @@
1
+ /**
2
+ * A content script keeping up with a page that changes (a single-page app,
3
+ * a list that loads more as you scroll): a MutationObserver that only
4
+ * collects what changed, and hands it over in one batch a little later,
5
+ * never touching the page itself. The extension's own elements are ignored
6
+ * (`ignore`), so what it adds never sets it off again. From Better Prices
7
+ * (`content/watch.js`), shared with Topicless.
8
+ *
9
+ * @module content/watch
10
+ */
11
+
12
+ /** How long after a change the batch waits for more (ms), by default. */
13
+ export const SETTLE_MS = 120;
14
+
15
+ /** The longest a batch waits while a page keeps changing (ms), by default. */
16
+ export const MAX_WAIT_MS = 600;
17
+
18
+ /**
19
+ * @typedef {object} WatchHandlers
20
+ * @property {(nodes: Node[]) => void} changed The nodes added or changed since the last batch, each once,
21
+ * none inside another.
22
+ * @property {(node: Node) => void} [removed] A node that left (at once, so what the extension added for it
23
+ * can go with it).
24
+ * @property {(node: Node) => boolean} [ignore] Whether a node added or removed is the extension's own.
25
+ * @property {number} [settleMs] How long a batch waits for more. Default {@link SETTLE_MS}.
26
+ * @property {number} [maxWaitMs] The longest a batch waits. Default {@link MAX_WAIT_MS}.
27
+ * @property {number} [regroupChildren] An element whose children changed is handed over again whole
28
+ * when it has at most this many element children (a split price's pieces; a list's new items
29
+ * are handed over themselves). Default 6; 0 never.
30
+ */
31
+
32
+ /**
33
+ * Watch `root` for changes.
34
+ *
35
+ * @param {Node} root - What to watch (the body, or the document element from `document_start`).
36
+ * @param {WatchHandlers} handlers - What to call, and how long to wait.
37
+ * @returns {() => void} Stops watching.
38
+ * @example
39
+ * const stop = watchChanges(document.body, {
40
+ * changed: (nodes) => nodes.forEach((node) => scanner.scan(node)),
41
+ * removed: (node) => scanner.forget(node),
42
+ * ignore: (node) => node instanceof Element && node.localName === 'better-prices-note',
43
+ * });
44
+ */
45
+ export function watchChanges(root, {
46
+ changed, removed = () => {}, ignore = () => false, settleMs = SETTLE_MS, maxWaitMs = MAX_WAIT_MS, regroupChildren = 6,
47
+ }) {
48
+ /** @type {Set<Node>} */
49
+ let pending = new Set();
50
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
51
+ let timer;
52
+ let firstAt = 0;
53
+
54
+ const flush = () => {
55
+ timer = undefined;
56
+ firstAt = 0;
57
+ const nodes = [...pending].filter((node) => node.isConnected);
58
+ pending = new Set();
59
+ // Each subtree once: a node inside another changed node goes with it.
60
+ const tops = nodes.filter((node) => !nodes.some((other) => other !== node && other.contains(node)));
61
+ if (tops.length) changed(tops);
62
+ };
63
+
64
+ const observer = new MutationObserver((mutations) => {
65
+ for (const mutation of mutations) {
66
+ const target = mutation.target;
67
+ if (mutation.type === 'characterData') {
68
+ pending.add(target);
69
+ continue;
70
+ }
71
+ let ours = true;
72
+ for (const node of mutation.addedNodes) {
73
+ if (ignore(node)) continue;
74
+ ours = false;
75
+ pending.add(node);
76
+ }
77
+ for (const node of mutation.removedNodes) {
78
+ if (ignore(node)) continue;
79
+ ours = false;
80
+ removed(node);
81
+ }
82
+ if (!ours && target.nodeType === 1 && /** @type {Element} */ (target).childElementCount <= regroupChildren) pending.add(target);
83
+ }
84
+ if (!pending.size) return;
85
+ const now = Date.now();
86
+ firstAt ||= now;
87
+ clearTimeout(timer);
88
+ timer = setTimeout(flush, Math.max(0, Math.min(settleMs, firstAt + maxWaitMs - now)));
89
+ });
90
+ observer.observe(root, { childList: true, subtree: true, characterData: true });
91
+ return () => {
92
+ observer.disconnect();
93
+ clearTimeout(timer);
94
+ };
95
+ }