@arraypress/extension-kit 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -159,13 +159,25 @@ be public before the store listing goes live.
159
159
  ## Page start-up
160
160
 
161
161
  ```js
162
- const t = setUpPage(chrome, { title: 'optionsTitle', substitutions: [name] });
162
+ const t = setUpPage(chrome, { title: 'optionsTitle' });
163
163
  ```
164
164
 
165
165
  Reports the page's colour scheme for the toolbar glyph (`watchColorScheme`; a failure is only
166
166
  logged), fills its `data-i18n` text (`localize`), sets its title from a message, and returns the
167
167
  page's `t`.
168
168
 
169
+ The extension's name lives in one message, `extShortName`. A message that names the extension
170
+ takes it as a placeholder whose content is `$1`, and every lookup without substitutions (`t(key)`,
171
+ `data-i18n`, `data-i18n-*`, the title) fills it in:
172
+
173
+ ```json
174
+ "optionsAbout": { "message": "About $NAME$", "placeholders": { "name": { "content": "$1" } } }
175
+ ```
176
+
177
+ ```html
178
+ <h2 class="section-title" data-i18n="optionsAbout"></h2> <!-- About Upright -->
179
+ ```
180
+
169
181
  ## Sites
170
182
 
171
183
  ```js
@@ -175,11 +187,22 @@ onSite(siteHost(location.href), neverSites); // on a listed site or a subdomain
175
187
  hostPatterns('github.com'); // ['*://github.com/*', '*://*.github.com/*'] (excludeMatches)
176
188
  originPattern('https://bit.ly/x'); // 'https://bit.ly/*' (permissions.request)
177
189
  siteLabel('https://example.com'); // 'example.com', as a list shows it
190
+ withSite(['a.com', 'b.com'], 'a.com'); // ['b.com', 'a.com'], what a list keeps after Add (also withoutSite, withSiteReplaced)
178
191
  ```
179
192
 
180
193
  A site is http or https with a dot in its host (or `localhost`); text without a scheme is https;
181
194
  `localhost:8080` is a host and port. Pure, so a content script can load `sites/sites.js` alone.
182
195
 
196
+ ## Time limits
197
+
198
+ ```js
199
+ const groups = await within(chrome.tabGroups.query({}), 2000, []); // a Chrome call that may never answer
200
+ ```
201
+
202
+ `within(promise, ms, fallback)`: the value in time, or the fallback (late or failed); never
203
+ rejects; its timer cleared once the work settles (the work itself isn't stopped). In the runtime
204
+ half, so a service worker or page can use it; the test helpers use it too.
205
+
183
206
  ## Page components
184
207
 
185
208
  Chrome's own components, made in the page by script (no markup to copy), styled by `chrome-ui.css`.
@@ -247,25 +270,50 @@ restarts the wait: 10 seconds, as chrome://bookmarks's (`TOAST_MS`), or `duratio
247
270
  ### Site list
248
271
 
249
272
  ```js
250
- // A short list with an add row (chrome://settings), the markup in the page:
251
- // <ul class="list" id="sites"></ul> <p class="row text" id="no-sites">…</p>
252
- // <form class="row add" id="add-site"><input type="text" …><button type="submit">Add</button></form>
273
+ // Memory Saver's way (chrome://settings/performance): an Add button and the dialog, ⋮ Edit/Remove.
274
+ // <div class="row"><span class="label secondary">…</span><button id="add-site" type="button">Add</button></div>
275
+ // <p class="row text" id="no-sites"><span class="label">No sites added</span></p> <ul class="list" id="sites"></ul>
253
276
  const sites = createSiteList({
254
- list: byId('sites'), empty: byId('no-sites'), form: byId('add-site', HTMLFormElement),
255
- parse: siteOrigin,
256
- add: async (origin) => (await chrome.permissions.request({ origins: [originPattern(origin)] })) && save(origin),
257
- remove: (origin) => unsave(origin),
258
- words: { remove: t('optionsRemove'), removeLabel: (site) => t('optionsRemoveLabel', [site]) },
277
+ list: byId('sites'), empty: byId('no-sites'), addButton: byId('add-site'),
278
+ parse: siteHost,
279
+ add: (site) => save({ sites: withSite(settings.sites, site) }),
280
+ remove: (site) => save({ sites: withoutSite(settings.sites, site) }),
281
+ replace: (site, edited) => save({ sites: withSiteReplaced(settings.sites, site, edited) }),
282
+ t, // the page's messages, under the standard keys below
259
283
  });
260
284
  sites.render(settings.sites);
261
285
 
262
- // Memory Saver's way (chrome://settings/performance): an Add button and the dialog, ⋮ Edit/Remove.
263
- createSiteList({ list, empty, addButton: byId('add-site'), parse: siteHost, add, remove, replace: (old, edited) => … });
286
+ // A short list with an add row (chrome://settings): `form` instead of `addButton`.
287
+ // <form class="row add" id="add-site"><input type="text" …><button type="submit">Add</button></form>
264
288
  ```
265
289
 
266
- `add` resolving to false keeps the typed text (access refused); `invalid(text)` hears what isn't
267
- a site in the add row; the dialog keeps its Add off with “Enter a site, like example.com” under
268
- the field. `words` translates `SITE_LIST_WORDS`.
290
+ `withSite` adds a site at the end, once (added again, it moves there); `withoutSite` takes it out;
291
+ `withSiteReplaced` edits it in place, merging it with a duplicate. A list that needs site access
292
+ asks for it in `add`/`replace` (the dialog's click still counts as the person's) and gives it back
293
+ in `remove`. `add` resolving to false keeps the add row's text (access refused); `invalid(text)`
294
+ hears what isn't a site in the add row; the dialog keeps its Add off with “Enter a site, like
295
+ example.com” under the field.
296
+
297
+ The words: Chrome's English (`SITE_LIST_WORDS`) without `t`; with it, the page's messages under
298
+ these keys (`SITE_LIST_MESSAGES`), so every extension's `_locales` names them alike. `words`
299
+ overrides only those that differ.
300
+
301
+ | Word | Key | English |
302
+ |---|---|---|
303
+ | `add` | `optionsAdd` | Add |
304
+ | `addSite` | `optionsAddSite` | Add site |
305
+ | `editSite` | `optionsEditSite` | Edit site |
306
+ | `edit` | `optionsEdit` | Edit |
307
+ | `remove` | `optionsRemove` | Remove |
308
+ | `save` | `optionsSave` | Save |
309
+ | `cancel` | `optionsCancel` | Cancel |
310
+ | `site` | `optionsSiteLabel` | Site |
311
+ | `invalid` | `optionsSiteInvalid` | Enter a site, like example.com |
312
+ | `moreActions` | `optionsMoreActions` | More actions for `$SITE$` (content `$1`; the ⋮ button) |
313
+ | `removeLabel` | `optionsRemoveLabel` | Remove `$SITE$` (content `$1`; the add row's Remove button) |
314
+
315
+ A list with an Add button needs every key but `optionsRemoveLabel`; one with an add row needs
316
+ `optionsRemove` and `optionsRemoveLabel`. A locale test can check them against `SITE_LIST_MESSAGES`.
269
317
 
270
318
  ## Build helpers (`@arraypress/extension-kit/build`)
271
319
 
@@ -300,7 +348,6 @@ except those listed, each with a reason:
300
348
  | `launchExtension(chromium, dir, options)` | Load an unpacked extension into a fresh profile; `{context, worker, extensionId, url(page), profile, close()}`. |
301
349
  | `extensionProfile(chromium, dir, options)` | A profile to start the browser on more than once (a restart, a first start with a changed manifest): `{dir, launch(), remove()}`. |
302
350
  | `checkRunner({timeout})` | Named checks that report and go on: `check(name, fn, {timeout})`, `fail(name)` for an error found outside a check, `finish()`. A check that runs past its limit (`CHECK_MS`, 60 s) fails, and the run goes on. |
303
- | `within(promise, ms, fallback)` | The value in time, or the fallback (late or failed); never rejects; its timer cleared. |
304
351
  | `eachLimited(items, limit, work)` | Work through a list at most `limit` at once, `work(item, slot, index)`. |
305
352
  | `realisticBrowser(options)`, `chromeUserAgent(browser)` | Launch options and a user agent for a browser that doesn't say it's automated, for live checks. |
306
353
  | `closeOnSignal(close)` | On Ctrl-C or a kill, close the run's browsers, then exit. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arraypress/extension-kit",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Shared pieces for Chrome extensions: Chrome's own page styles, a Chrome Sync settings store, the light/dark toolbar glyph, About links, and build and test helpers.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -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', 'settings', 'sites', 'styles', 'toolbar', 'ui']);
31
+ export const RUNTIME_ENTRIES = Object.freeze(['index.js', 'index.d.ts', 'about', 'commands', 'dom', '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');
package/src/dom/i18n.d.ts CHANGED
@@ -4,6 +4,11 @@
4
4
  * `data-i18n-<attribute>="key"` (sets that attribute, e.g.
5
5
  * `data-i18n-placeholder`, `data-i18n-aria-label`).
6
6
  *
7
+ * The extension's name lives in one message, `extShortName`; a message that
8
+ * names the extension takes it as a placeholder whose content is `$1`
9
+ * (`"About $NAME$"`), and every lookup made without substitutions fills it
10
+ * in, in markup and script alike.
11
+ *
7
12
  * @module dom/i18n
8
13
  */
9
14
  export type I18nApi = {
@@ -19,18 +24,21 @@ export type I18nApi = {
19
24
  */
20
25
  /**
21
26
  * A message lookup that never comes back blank: a missing key shows as
22
- * itself, so a typo is visible on screen instead of an empty label.
27
+ * itself, so a typo is visible on screen instead of an empty label. Without
28
+ * substitutions, `$1` is the extension's name (`extShortName`).
23
29
  *
24
30
  * @param {Pick<I18nApi, 'getMessage'>} i18n - `chrome.i18n`.
25
31
  * @returns {(key: string, substitutions?: string | string[]) => string} Looks a key up.
26
32
  * @example
27
33
  * const t = translator(chrome.i18n);
28
34
  * t('optionsVersion', ['1.0.0']); // 'Version 1.0.0'
35
+ * t('optionsAbout'); // 'About Upright', from "About $NAME$"
29
36
  */
30
37
  export declare function translator(i18n: Pick<I18nApi, 'getMessage'>): (key: string, substitutions?: string | string[]) => string;
31
38
  /**
32
- * Fill every `data-i18n*` attribute under `root`, and, for a whole document,
33
- * set its `lang` to Chrome's UI language.
39
+ * Fill every `data-i18n*` attribute under `root` (a message that names the
40
+ * extension gets its name), and, for a whole document, set its `lang` to
41
+ * Chrome's UI language.
34
42
  *
35
43
  * @param {ParentNode} root - A document or an element.
36
44
  * @param {I18nApi} i18n - `chrome.i18n`.
package/src/dom/i18n.js CHANGED
@@ -4,9 +4,17 @@
4
4
  * `data-i18n-<attribute>="key"` (sets that attribute, e.g.
5
5
  * `data-i18n-placeholder`, `data-i18n-aria-label`).
6
6
  *
7
+ * The extension's name lives in one message, `extShortName`; a message that
8
+ * names the extension takes it as a placeholder whose content is `$1`
9
+ * (`"About $NAME$"`), and every lookup made without substitutions fills it
10
+ * in, in markup and script alike.
11
+ *
7
12
  * @module dom/i18n
8
13
  */
9
14
 
15
+ /** The message that holds the extension's name. */
16
+ const NAME_MESSAGE = 'extShortName';
17
+
10
18
  /**
11
19
  * `chrome.i18n`, as far as pages need it.
12
20
  *
@@ -17,21 +25,26 @@
17
25
 
18
26
  /**
19
27
  * A message lookup that never comes back blank: a missing key shows as
20
- * itself, so a typo is visible on screen instead of an empty label.
28
+ * itself, so a typo is visible on screen instead of an empty label. Without
29
+ * substitutions, `$1` is the extension's name (`extShortName`).
21
30
  *
22
31
  * @param {Pick<I18nApi, 'getMessage'>} i18n - `chrome.i18n`.
23
32
  * @returns {(key: string, substitutions?: string | string[]) => string} Looks a key up.
24
33
  * @example
25
34
  * const t = translator(chrome.i18n);
26
35
  * t('optionsVersion', ['1.0.0']); // 'Version 1.0.0'
36
+ * t('optionsAbout'); // 'About Upright', from "About $NAME$"
27
37
  */
28
38
  export function translator(i18n) {
29
- return (key, substitutions) => i18n.getMessage(key, substitutions) || key;
39
+ const name = i18n.getMessage(NAME_MESSAGE);
40
+ const named = name ? [name] : undefined;
41
+ return (key, substitutions = named) => i18n.getMessage(key, substitutions) || key;
30
42
  }
31
43
 
32
44
  /**
33
- * Fill every `data-i18n*` attribute under `root`, and, for a whole document,
34
- * set its `lang` to Chrome's UI language.
45
+ * Fill every `data-i18n*` attribute under `root` (a message that names the
46
+ * extension gets its name), and, for a whole document, set its `lang` to
47
+ * Chrome's UI language.
35
48
  *
36
49
  * @param {ParentNode} root - A document or an element.
37
50
  * @param {I18nApi} i18n - `chrome.i18n`.
@@ -13,7 +13,7 @@ export type PageSetup = {
13
13
  */
14
14
  title?: string;
15
15
  /**
16
- * The title's placeholders (the extension's name).
16
+ * The title's placeholders. Default: the extension's name (`translator`).
17
17
  */
18
18
  substitutions?: string | string[];
19
19
  /**
@@ -32,7 +32,7 @@ export type PageSetup = {
32
32
  *
33
33
  * @typedef {object} PageSetup
34
34
  * @property {string} [title] The title's message (“optionsTitle”). Default: the title is left as it is.
35
- * @property {string | string[]} [substitutions] The title's placeholders (the extension's name).
35
+ * @property {string | string[]} [substitutions] The title's placeholders. Default: the extension's name (`translator`).
36
36
  * @property {Document} [doc] The page. Default `document`.
37
37
  * @property {SchemeMedia} [media] The scheme to report. Default the page's `matchMedia(DARK_QUERY)`.
38
38
  */
@@ -45,7 +45,8 @@ export type PageSetup = {
45
45
  * @returns {(key: string, substitutions?: string | string[]) => string} The page's message lookup
46
46
  * (`translator(chrome.i18n)`).
47
47
  * @example
48
- * const t = setUpPage(chrome, { title: 'optionsTitle', substitutions: [t('extShortName')] });
48
+ * const t = setUpPage(chrome, { title: 'optionsTitle' }); // "$NAME$ settings": 'Upright settings'
49
+ * t('optionsGetHelp'); // "Get help with $NAME$": 'Get help with Upright'
49
50
  * // The popup, whose title Chrome doesn't show:
50
51
  * const t = setUpPage(chrome);
51
52
  */
package/src/dom/setup.js CHANGED
@@ -17,7 +17,7 @@ import { DARK_QUERY, watchColorScheme } from '../toolbar/scheme.js';
17
17
  *
18
18
  * @typedef {object} PageSetup
19
19
  * @property {string} [title] The title's message (“optionsTitle”). Default: the title is left as it is.
20
- * @property {string | string[]} [substitutions] The title's placeholders (the extension's name).
20
+ * @property {string | string[]} [substitutions] The title's placeholders. Default: the extension's name (`translator`).
21
21
  * @property {Document} [doc] The page. Default `document`.
22
22
  * @property {SchemeMedia} [media] The scheme to report. Default the page's `matchMedia(DARK_QUERY)`.
23
23
  */
@@ -31,7 +31,8 @@ import { DARK_QUERY, watchColorScheme } from '../toolbar/scheme.js';
31
31
  * @returns {(key: string, substitutions?: string | string[]) => string} The page's message lookup
32
32
  * (`translator(chrome.i18n)`).
33
33
  * @example
34
- * const t = setUpPage(chrome, { title: 'optionsTitle', substitutions: [t('extShortName')] });
34
+ * const t = setUpPage(chrome, { title: 'optionsTitle' }); // "$NAME$ settings": 'Upright settings'
35
+ * t('optionsGetHelp'); // "Get help with $NAME$": 'Get help with Upright'
35
36
  * // The popup, whose title Chrome doesn't show:
36
37
  * const t = setUpPage(chrome);
37
38
  */
package/src/index.d.ts CHANGED
@@ -20,12 +20,13 @@ export { setUpPage } from './dom/setup.js';
20
20
  export { createSettingsSaver } from './settings/saver.js';
21
21
  export { MAX_SYNC_ITEM_BYTES } from './settings/storage.js';
22
22
  export { createSettingsStore } from './settings/store.js';
23
- export { MAX_HOST_LENGTH, hostPatterns, onSite, originPattern, siteHost, siteLabel, siteOrigin } from './sites/sites.js';
23
+ export { MAX_HOST_LENGTH, hostPatterns, onSite, originPattern, siteHost, siteLabel, siteOrigin, withSite, withSiteReplaced, withoutSite, } from './sites/sites.js';
24
+ export { within } from './time/time.js';
24
25
  export { POLL_MS, watchSchemeOffscreen } from './toolbar/offscreen.js';
25
26
  export { DARK_QUERY, TOOLBAR_DARK_KEY, TOOLBAR_SCHEME, reportColorScheme, schemeSender, watchColorScheme } from './toolbar/scheme.js';
26
27
  export { OFFSCREEN_PATH, TOOLBAR_ICON_DIR, createToolbarTheme, startToolbarTheme, toolbarIcon, watchFromOffscreen, } from './toolbar/theme.js';
27
28
  export { MENU_EDGE, createMenu, isInside, menuPosition, moreActionsButton } from './ui/menu.js';
28
- export { createSiteList } from './ui/site-list.js';
29
+ export { SITE_LIST_MESSAGES, SITE_LIST_WORDS, createSiteList } from './ui/site-list.js';
29
30
  export { askText, checkText } from './ui/text-dialog.js';
30
31
  export { TOAST_MS, createToast } from './ui/toast.js';
31
32
  export type ActionMenu = import('./ui/menu.js').ActionMenu;
package/src/index.js CHANGED
@@ -21,14 +21,17 @@ export { setUpPage } from './dom/setup.js';
21
21
  export { createSettingsSaver } from './settings/saver.js';
22
22
  export { MAX_SYNC_ITEM_BYTES } from './settings/storage.js';
23
23
  export { createSettingsStore } from './settings/store.js';
24
- export { MAX_HOST_LENGTH, hostPatterns, onSite, originPattern, siteHost, siteLabel, siteOrigin } from './sites/sites.js';
24
+ export {
25
+ MAX_HOST_LENGTH, hostPatterns, onSite, originPattern, siteHost, siteLabel, siteOrigin, withSite, withSiteReplaced, withoutSite,
26
+ } from './sites/sites.js';
27
+ export { within } from './time/time.js';
25
28
  export { POLL_MS, watchSchemeOffscreen } from './toolbar/offscreen.js';
26
29
  export { DARK_QUERY, TOOLBAR_DARK_KEY, TOOLBAR_SCHEME, reportColorScheme, schemeSender, watchColorScheme } from './toolbar/scheme.js';
27
30
  export {
28
31
  OFFSCREEN_PATH, TOOLBAR_ICON_DIR, createToolbarTheme, startToolbarTheme, toolbarIcon, watchFromOffscreen,
29
32
  } from './toolbar/theme.js';
30
33
  export { MENU_EDGE, createMenu, isInside, menuPosition, moreActionsButton } from './ui/menu.js';
31
- export { createSiteList } from './ui/site-list.js';
34
+ export { SITE_LIST_MESSAGES, SITE_LIST_WORDS, createSiteList } from './ui/site-list.js';
32
35
  export { askText, checkText } from './ui/text-dialog.js';
33
36
  export { TOAST_MS, createToast } from './ui/toast.js';
34
37
 
@@ -3,7 +3,8 @@
3
3
  * "https://www.Example.com/shop") and as pages report them (`location.href`):
4
4
  * the origin an extension asks Chrome for access to, the host a per-site
5
5
  * on/off list keeps, whether a page is on a listed site (subdomains
6
- * included), and the match patterns for both. Pure.
6
+ * included), the match patterns for both, and a list with a site added,
7
+ * taken out or edited. Pure.
7
8
  *
8
9
  * A site is a web host: http or https, with a dot in its name (or
9
10
  * `localhost`). Text without a scheme is read as https.
@@ -80,3 +81,34 @@ export declare function originPattern(url: string): string;
80
81
  * siteLabel('http://example.com'); // 'http://example.com'
81
82
  */
82
83
  export declare function siteLabel(site: string): string;
84
+ /**
85
+ * A site list with `site` added: at the end, once (a site added again moves
86
+ * there).
87
+ *
88
+ * @param {readonly string[]} sites - The list.
89
+ * @param {string} site - The site to add.
90
+ * @returns {string[]} A new list.
91
+ * @example
92
+ * withSite(['a.com', 'b.com'], 'a.com'); // ['b.com', 'a.com']
93
+ */
94
+ export declare function withSite(sites: readonly string[], site: string): string[];
95
+ /**
96
+ * A site list without `site`.
97
+ *
98
+ * @param {readonly string[]} sites - The list.
99
+ * @param {string} site - The site to take out.
100
+ * @returns {string[]} A new list.
101
+ */
102
+ export declare function withoutSite(sites: readonly string[], site: string): string[];
103
+ /**
104
+ * A site list with `site` edited to `edited`, in its place, once (an edit
105
+ * to a site already listed merges the two).
106
+ *
107
+ * @param {readonly string[]} sites - The list.
108
+ * @param {string} site - The site as it was.
109
+ * @param {string} edited - What it was edited to.
110
+ * @returns {string[]} A new list.
111
+ * @example
112
+ * withSiteReplaced(['a.com', 'b.com'], 'a.com', 'b.com'); // ['b.com']
113
+ */
114
+ export declare function withSiteReplaced(sites: readonly string[], site: string, edited: string): string[];
@@ -3,7 +3,8 @@
3
3
  * "https://www.Example.com/shop") and as pages report them (`location.href`):
4
4
  * the origin an extension asks Chrome for access to, the host a per-site
5
5
  * on/off list keeps, whether a page is on a listed site (subdomains
6
- * included), and the match patterns for both. Pure.
6
+ * included), the match patterns for both, and a list with a site added,
7
+ * taken out or edited. Pure.
7
8
  *
8
9
  * A site is a web host: http or https, with a dot in its name (or
9
10
  * `localhost`). Text without a scheme is read as https.
@@ -126,3 +127,43 @@ export function originPattern(url) {
126
127
  export function siteLabel(site) {
127
128
  return site.replace(/^https:\/\//, '');
128
129
  }
130
+
131
+ /**
132
+ * A site list with `site` added: at the end, once (a site added again moves
133
+ * there).
134
+ *
135
+ * @param {readonly string[]} sites - The list.
136
+ * @param {string} site - The site to add.
137
+ * @returns {string[]} A new list.
138
+ * @example
139
+ * withSite(['a.com', 'b.com'], 'a.com'); // ['b.com', 'a.com']
140
+ */
141
+ export function withSite(sites, site) {
142
+ return [...withoutSite(sites, site), site];
143
+ }
144
+
145
+ /**
146
+ * A site list without `site`.
147
+ *
148
+ * @param {readonly string[]} sites - The list.
149
+ * @param {string} site - The site to take out.
150
+ * @returns {string[]} A new list.
151
+ */
152
+ export function withoutSite(sites, site) {
153
+ return sites.filter((s) => s !== site);
154
+ }
155
+
156
+ /**
157
+ * A site list with `site` edited to `edited`, in its place, once (an edit
158
+ * to a site already listed merges the two).
159
+ *
160
+ * @param {readonly string[]} sites - The list.
161
+ * @param {string} site - The site as it was.
162
+ * @param {string} edited - What it was edited to.
163
+ * @returns {string[]} A new list.
164
+ * @example
165
+ * withSiteReplaced(['a.com', 'b.com'], 'a.com', 'b.com'); // ['b.com']
166
+ */
167
+ export function withSiteReplaced(sites, site, edited) {
168
+ return [...new Set(sites.map((s) => (s === site ? edited : s)))];
169
+ }
@@ -8,7 +8,7 @@
8
8
  * @module testing/browser
9
9
  */
10
10
 
11
- import { within } from './time.js';
11
+ import { within } from '../time/time.js';
12
12
 
13
13
  /** @import { Browser } from 'playwright' */
14
14
 
@@ -12,7 +12,7 @@ import assert from 'node:assert/strict';
12
12
  import { mkdtemp, rm } from 'node:fs/promises';
13
13
  import { tmpdir } from 'node:os';
14
14
  import path from 'node:path';
15
- import { deadline } from './time.js';
15
+ import { deadline } from '../time/time.js';
16
16
 
17
17
  /** @import { BrowserContext, BrowserType, CDPSession, Page, Worker } from 'playwright' */
18
18
 
@@ -10,7 +10,6 @@ export { TEST_BUILDS_ENV, testBuildDir } from '../build/test-build.js';
10
10
  export { chromeUserAgent, closeOnSignal, realisticBrowser } from './browser.js';
11
11
  export { CHECK_MS, checkRunner, checkToolbarTheme, extensionProfile, launchExtension, offscreenEmulator, recordIconCalls, until, } from './e2e.js';
12
12
  export { eachLimited } from './pool.js';
13
- export { within } from './time.js';
14
13
  export { serve } from './server.js';
15
14
  export { SYNC_WRITES_PER_MINUTE, fakeChromeStorage } from './fake-storage.js';
16
15
  export { filesUnder, manifestReferences, missingReferences, referencedPath, zipEntries } from './package.js';
@@ -13,7 +13,6 @@ export {
13
13
  CHECK_MS, checkRunner, checkToolbarTheme, extensionProfile, launchExtension, offscreenEmulator, recordIconCalls, until,
14
14
  } from './e2e.js';
15
15
  export { eachLimited } from './pool.js';
16
- export { within } from './time.js';
17
16
  export { serve } from './server.js';
18
17
  export { SYNC_WRITES_PER_MINUTE, fakeChromeStorage } from './fake-storage.js';
19
18
  export { filesUnder, manifestReferences, missingReferences, referencedPath, zipEntries } from './package.js';
@@ -1,8 +1,9 @@
1
1
  /**
2
- * Time limits for tests that drive a real browser, where a page that stops
3
- * responding would otherwise hold a run up forever.
2
+ * Time limits for work that may never finish: a Chrome call a service worker
3
+ * shouldn't wait on forever, or, in tests that drive a real browser, a page
4
+ * that stops responding. Runs anywhere (worker, page, Node).
4
5
  *
5
- * @module testing/time
6
+ * @module time/time
6
7
  */
7
8
  /**
8
9
  * Settle `promise` within `ms`, or settle the way `late` says. The timer is
@@ -19,9 +20,9 @@
19
20
  export declare function deadline<T>(promise: Promise<T>, ms: number, late: (resolve: (value: T) => void, reject: (error: Error) => void) => void): Promise<T>;
20
21
  /**
21
22
  * A promise's value within a time limit, or `fallback` when it's late or
22
- * fails: for a step a live run should give up on and go past (a renderer
23
- * that stopped responding, a close that never returns). The work itself
24
- * isn't stopped; the timer is cleared once it settles.
23
+ * fails: for a step to give up on and go past (a Chrome call that never
24
+ * answers, a renderer that stopped responding, a close that never returns).
25
+ * The work itself isn't stopped; the timer is cleared once it settles.
25
26
  *
26
27
  * @template T
27
28
  * @param {Promise<T>} promise - The work.
@@ -1,8 +1,9 @@
1
1
  /**
2
- * Time limits for tests that drive a real browser, where a page that stops
3
- * responding would otherwise hold a run up forever.
2
+ * Time limits for work that may never finish: a Chrome call a service worker
3
+ * shouldn't wait on forever, or, in tests that drive a real browser, a page
4
+ * that stops responding. Runs anywhere (worker, page, Node).
4
5
  *
5
- * @module testing/time
6
+ * @module time/time
6
7
  */
7
8
 
8
9
  /**
@@ -28,9 +29,9 @@ export function deadline(promise, ms, late) {
28
29
 
29
30
  /**
30
31
  * A promise's value within a time limit, or `fallback` when it's late or
31
- * fails: for a step a live run should give up on and go past (a renderer
32
- * that stopped responding, a close that never returns). The work itself
33
- * isn't stopped; the timer is cleared once it settles.
32
+ * fails: for a step to give up on and go past (a Chrome call that never
33
+ * answers, a renderer that stopped responding, a close that never returns).
34
+ * The work itself isn't stopped; the timer is cleared once it settles.
34
35
  *
35
36
  * @template T
36
37
  * @param {Promise<T>} promise - The work.
@@ -90,6 +90,14 @@ export type SiteListWords = {
90
90
  */
91
91
  /** @type {Readonly<SiteListWords>} */
92
92
  export declare const SITE_LIST_WORDS: Readonly<SiteListWords>;
93
+ /**
94
+ * The message key each word is read from when a page passes its `t`, so
95
+ * every extension's `_locales` names them alike. `removeLabel` and
96
+ * `moreActions` take the site as `$1` (`"More actions for $SITE$"`).
97
+ *
98
+ * @type {Readonly<Record<keyof SiteListWords, string>>}
99
+ */
100
+ export declare const SITE_LIST_MESSAGES: Readonly<Record<keyof SiteListWords, string>>;
93
101
  export type SiteListOptions = {
94
102
  /**
95
103
  * The `<ul class="list">` to fill.
@@ -140,7 +148,13 @@ export type SiteListOptions = {
140
148
  */
141
149
  maxLength?: number;
142
150
  /**
143
- * Translations of {@link SITE_LIST_WORDS}.
151
+ * The page's message lookup
152
+ * (`setUpPage`'s): the words are then its messages under {@link SITE_LIST_MESSAGES}. Without it,
153
+ * Chrome's English ({@link SITE_LIST_WORDS}).
154
+ */
155
+ t?: (key: string, substitutions?: string | string[]) => string;
156
+ /**
157
+ * Words that differ from those, by name.
144
158
  */
145
159
  words?: Partial<SiteListWords>;
146
160
  };
@@ -170,7 +184,10 @@ export type SiteList = {
170
184
  * @property {(text: string) => void} [invalid] The add row: called with what was typed when it isn't a site.
171
185
  * @property {(site: string) => string} [label] How a site reads. Default {@link siteLabel}.
172
186
  * @property {number} [maxLength] The dialog field's `maxlength`. Default {@link MAX_HOST_LENGTH}.
173
- * @property {Partial<SiteListWords>} [words] Translations of {@link SITE_LIST_WORDS}.
187
+ * @property {(key: string, substitutions?: string | string[]) => string} [t] The page's message lookup
188
+ * (`setUpPage`'s): the words are then its messages under {@link SITE_LIST_MESSAGES}. Without it,
189
+ * Chrome's English ({@link SITE_LIST_WORDS}).
190
+ * @property {Partial<SiteListWords>} [words] Words that differ from those, by name.
174
191
  */
175
192
  /**
176
193
  * A site list.
@@ -190,10 +207,10 @@ export type SiteList = {
190
207
  * const never = createSiteList({
191
208
  * list: byId('never-sites'), form: byId('add-never', HTMLFormElement), empty: byId('no-sites'),
192
209
  * parse: siteHost,
193
- * add: (host) => save({ neverSites: [...settings.neverSites, host] }),
194
- * remove: (host) => save({ neverSites: settings.neverSites.filter((h) => h !== host) }),
195
- * words: { remove: t('optionsRemove'), removeLabel: (site) => t('optionsRemoveLabel', [site]) },
210
+ * add: (host) => save({ neverSites: withSite(settings.neverSites, host) }),
211
+ * remove: (host) => save({ neverSites: withoutSite(settings.neverSites, host) }),
212
+ * t, // its words from optionsAdd, optionsRemove, …
196
213
  * });
197
214
  * never.render(settings.neverSites);
198
215
  */
199
- export declare function createSiteList({ list, empty, form, addButton, parse, add, remove, replace, invalid, label, maxLength, words, }: SiteListOptions): SiteList;
216
+ export declare function createSiteList({ list, empty, form, addButton, parse, add, remove, replace, invalid, label, maxLength, t, words, }: SiteListOptions): SiteList;
@@ -63,6 +63,43 @@ export const SITE_LIST_WORDS = Object.freeze({
63
63
  moreActions: (/** @type {string} */ label) => `More actions for “${label}”`,
64
64
  });
65
65
 
66
+ /**
67
+ * The message key each word is read from when a page passes its `t`, so
68
+ * every extension's `_locales` names them alike. `removeLabel` and
69
+ * `moreActions` take the site as `$1` (`"More actions for $SITE$"`).
70
+ *
71
+ * @type {Readonly<Record<keyof SiteListWords, string>>}
72
+ */
73
+ export const SITE_LIST_MESSAGES = Object.freeze({
74
+ add: 'optionsAdd',
75
+ addSite: 'optionsAddSite',
76
+ editSite: 'optionsEditSite',
77
+ edit: 'optionsEdit',
78
+ remove: 'optionsRemove',
79
+ save: 'optionsSave',
80
+ cancel: 'optionsCancel',
81
+ site: 'optionsSiteLabel',
82
+ invalid: 'optionsSiteInvalid',
83
+ removeLabel: 'optionsRemoveLabel',
84
+ moreActions: 'optionsMoreActions',
85
+ });
86
+
87
+ /**
88
+ * The list's words from a page's messages, under {@link SITE_LIST_MESSAGES}.
89
+ *
90
+ * @param {(key: string, substitutions?: string | string[]) => string} t - The page's message lookup.
91
+ * @returns {SiteListWords} The words.
92
+ */
93
+ function translatedWords(t) {
94
+ const m = SITE_LIST_MESSAGES;
95
+ return {
96
+ add: t(m.add), addSite: t(m.addSite), editSite: t(m.editSite), edit: t(m.edit), remove: t(m.remove),
97
+ save: t(m.save), cancel: t(m.cancel), site: t(m.site), invalid: t(m.invalid),
98
+ removeLabel: (label) => t(m.removeLabel, [label]),
99
+ moreActions: (label) => t(m.moreActions, [label]),
100
+ };
101
+ }
102
+
66
103
  /**
67
104
  * What {@link createSiteList} takes: the list, one way to add to it, and
68
105
  * what adding, removing and editing do.
@@ -83,7 +120,10 @@ export const SITE_LIST_WORDS = Object.freeze({
83
120
  * @property {(text: string) => void} [invalid] The add row: called with what was typed when it isn't a site.
84
121
  * @property {(site: string) => string} [label] How a site reads. Default {@link siteLabel}.
85
122
  * @property {number} [maxLength] The dialog field's `maxlength`. Default {@link MAX_HOST_LENGTH}.
86
- * @property {Partial<SiteListWords>} [words] Translations of {@link SITE_LIST_WORDS}.
123
+ * @property {(key: string, substitutions?: string | string[]) => string} [t] The page's message lookup
124
+ * (`setUpPage`'s): the words are then its messages under {@link SITE_LIST_MESSAGES}. Without it,
125
+ * Chrome's English ({@link SITE_LIST_WORDS}).
126
+ * @property {Partial<SiteListWords>} [words] Words that differ from those, by name.
87
127
  */
88
128
 
89
129
  /**
@@ -105,17 +145,17 @@ export const SITE_LIST_WORDS = Object.freeze({
105
145
  * const never = createSiteList({
106
146
  * list: byId('never-sites'), form: byId('add-never', HTMLFormElement), empty: byId('no-sites'),
107
147
  * parse: siteHost,
108
- * add: (host) => save({ neverSites: [...settings.neverSites, host] }),
109
- * remove: (host) => save({ neverSites: settings.neverSites.filter((h) => h !== host) }),
110
- * words: { remove: t('optionsRemove'), removeLabel: (site) => t('optionsRemoveLabel', [site]) },
148
+ * add: (host) => save({ neverSites: withSite(settings.neverSites, host) }),
149
+ * remove: (host) => save({ neverSites: withoutSite(settings.neverSites, host) }),
150
+ * t, // its words from optionsAdd, optionsRemove, …
111
151
  * });
112
152
  * never.render(settings.neverSites);
113
153
  */
114
154
  export function createSiteList({
115
- list, empty, form, addButton, parse, add, remove, replace, invalid, label = siteLabel, maxLength = MAX_HOST_LENGTH, words,
155
+ list, empty, form, addButton, parse, add, remove, replace, invalid, label = siteLabel, maxLength = MAX_HOST_LENGTH, t, words,
116
156
  }) {
117
157
  const doc = list.ownerDocument;
118
- const w = { ...SITE_LIST_WORDS, ...words };
158
+ const w = { ...(t ? translatedWords(t) : SITE_LIST_WORDS), ...words };
119
159
  const menu = addButton ? createMenu({ doc }) : null;
120
160
 
121
161
  /**