@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
@@ -74,14 +74,45 @@ export declare const MAX_SYNC_ITEM_BYTES = 8192;
74
74
  */
75
75
  export declare function pick<T extends Record<string, unknown>>(object: T, keys: readonly string[]): Partial<T>;
76
76
  /**
77
- * How many bytes sync counts for one item: the key plus the value's JSON,
78
- * UTF-8 encoded, as Chrome measures `QUOTA_BYTES_PER_ITEM`.
77
+ * How many bytes sync counts for one item, as Chrome measures
78
+ * `QUOTA_BYTES_PER_ITEM`: the key plus the value's JSON as Chrome's own
79
+ * writer spells it, UTF-8 encoded. That writer (`base::WriteJson`) escapes
80
+ * `<` as `\u003C` and U+2028 / U+2029 as `\u2028` / `\u2029`, six bytes each,
81
+ * so text full of `<` (an HTML template) weighs far more in sync than its
82
+ * `JSON.stringify` length says. Checked against Chromium's
83
+ * `settings_storage_quota_enforcer.cc` and `base/json/string_escape.cc`.
79
84
  *
80
85
  * @param {string} key - The item's key.
81
86
  * @param {unknown} value - The item's value.
82
87
  * @returns {number} Bytes.
88
+ * @example
89
+ * syncItemBytes('k', 'a<b'); // 11: k + "a\u003Cb"
83
90
  */
84
91
  export declare function syncItemBytes(key: string, value: unknown): number;
92
+ /**
93
+ * Whether two stored values are the same, whatever order their objects' keys come in.
94
+ *
95
+ * @param {unknown} a - One value.
96
+ * @param {unknown} b - The other.
97
+ * @returns {boolean} True when they'd be stored alike.
98
+ * @example
99
+ * sameValue({ a: 1, b: [2] }, { b: [2], a: 1 }); // true
100
+ */
101
+ export declare function sameValue(a: unknown, b: unknown): boolean;
102
+ /**
103
+ * Run `work` while nothing else runs under the same name: in this page, the
104
+ * extension's other pages and its service worker alike (Web Locks, which an
105
+ * extension's contexts share), so a read-modify-write of one storage item
106
+ * can't interleave with another. `work` is told whether it had to wait.
107
+ *
108
+ * @template T
109
+ * @param {string} name - The lock's name.
110
+ * @param {(waited: boolean) => Promise<T>} work - What to do; `waited` is true when other work held the name first.
111
+ * @returns {Promise<T>} What `work` gives.
112
+ * @example
113
+ * await exclusive('unsyncedSettings', async () => storage.local.set({ held: { ...(await storage.local.get('held')).held, days } }));
114
+ */
115
+ export declare function exclusive<T>(name: string, work: (waited: boolean) => Promise<T>): Promise<T>;
85
116
  /**
86
117
  * Whether an object has no own keys.
87
118
  *
@@ -53,16 +53,86 @@ export function pick(object, keys) {
53
53
  return /** @type {Partial<T>} */ (Object.fromEntries(keys.filter((key) => key in object).map((key) => [key, object[key]])));
54
54
  }
55
55
 
56
+ /** What Chrome's JSON writer spells as six bytes where `JSON.stringify` writes fewer: `<` (one byte), U+2028 and U+2029 (three each). */
57
+ const WIDER_IN_CHROME = /[<\u2028\u2029]/g;
58
+
56
59
  /**
57
- * How many bytes sync counts for one item: the key plus the value's JSON,
58
- * UTF-8 encoded, as Chrome measures `QUOTA_BYTES_PER_ITEM`.
60
+ * How many bytes sync counts for one item, as Chrome measures
61
+ * `QUOTA_BYTES_PER_ITEM`: the key plus the value's JSON as Chrome's own
62
+ * writer spells it, UTF-8 encoded. That writer (`base::WriteJson`) escapes
63
+ * `<` as `\u003C` and U+2028 / U+2029 as `\u2028` / `\u2029`, six bytes each,
64
+ * so text full of `<` (an HTML template) weighs far more in sync than its
65
+ * `JSON.stringify` length says. Checked against Chromium's
66
+ * `settings_storage_quota_enforcer.cc` and `base/json/string_escape.cc`.
59
67
  *
60
68
  * @param {string} key - The item's key.
61
69
  * @param {unknown} value - The item's value.
62
70
  * @returns {number} Bytes.
71
+ * @example
72
+ * syncItemBytes('k', 'a<b'); // 11: k + "a\u003Cb"
63
73
  */
64
74
  export function syncItemBytes(key, value) {
65
- return new TextEncoder().encode(key + JSON.stringify(value)).length;
75
+ const json = String(JSON.stringify(value));
76
+ let wider = 0;
77
+ for (const [character] of json.matchAll(WIDER_IN_CHROME)) wider += character === '<' ? 5 : 3;
78
+ return new TextEncoder().encode(key + json).length + wider;
79
+ }
80
+
81
+ /**
82
+ * A value as text that reads the same however its objects' keys are ordered:
83
+ * Chrome hands a stored object back with its keys sorted.
84
+ *
85
+ * @param {unknown} value - Anything JSON can hold.
86
+ * @returns {string} Its JSON, object keys sorted.
87
+ */
88
+ function canonical(value) {
89
+ return String(JSON.stringify(value, (_, inner) => (inner && typeof inner === 'object' && !Array.isArray(inner)
90
+ ? Object.fromEntries(Object.keys(inner).sort().map((key) => [key, inner[key]]))
91
+ : inner)));
92
+ }
93
+
94
+ /**
95
+ * Whether two stored values are the same, whatever order their objects' keys come in.
96
+ *
97
+ * @param {unknown} a - One value.
98
+ * @param {unknown} b - The other.
99
+ * @returns {boolean} True when they'd be stored alike.
100
+ * @example
101
+ * sameValue({ a: 1, b: [2] }, { b: [2], a: 1 }); // true
102
+ */
103
+ export function sameValue(a, b) {
104
+ return canonical(a) === canonical(b);
105
+ }
106
+
107
+ /** Where there are no Web Locks (an old Node): each name's last piece of work, so the next waits for it. @type {Map<string, Promise<unknown>>} */
108
+ const chains = new Map();
109
+
110
+ /**
111
+ * Run `work` while nothing else runs under the same name: in this page, the
112
+ * extension's other pages and its service worker alike (Web Locks, which an
113
+ * extension's contexts share), so a read-modify-write of one storage item
114
+ * can't interleave with another. `work` is told whether it had to wait.
115
+ *
116
+ * @template T
117
+ * @param {string} name - The lock's name.
118
+ * @param {(waited: boolean) => Promise<T>} work - What to do; `waited` is true when other work held the name first.
119
+ * @returns {Promise<T>} What `work` gives.
120
+ * @example
121
+ * await exclusive('unsyncedSettings', async () => storage.local.set({ held: { ...(await storage.local.get('held')).held, days } }));
122
+ */
123
+ export async function exclusive(name, work) {
124
+ const locks = /** @type {{navigator?: {locks?: LockManager}}} */ (globalThis).navigator?.locks;
125
+ if (locks) {
126
+ /** @type {{value: T} | null} */
127
+ const free = await locks.request(name, { ifAvailable: true }, async (lock) => (lock ? { value: await work(false) } : null));
128
+ return free ? free.value : locks.request(name, () => work(true));
129
+ }
130
+ const before = chains.get(name);
131
+ const run = (before ?? Promise.resolve()).then(() => work(before !== undefined));
132
+ const settled = run.catch(() => {});
133
+ chains.set(name, settled);
134
+ settled.then(() => { if (chains.get(name) === settled) chains.delete(name); });
135
+ return run;
66
136
  }
67
137
 
68
138
  /**
@@ -8,6 +8,15 @@
8
8
  * `chrome.storage.local` instead. Local also holds any write sync refused
9
9
  * (too many writes a minute, or an item over its size limit), kept under
10
10
  * `unsyncedKey`, read as if synced, and retried, so a change is never lost.
11
+ * Beside it (`<unsyncedKey>Was`) is what sync held for each when it was
12
+ * refused: once sync holds something else (the setting was changed since on
13
+ * another computer), the held change is the older one and is dropped, not
14
+ * read over the newer one or sent up over it.
15
+ *
16
+ * Holding a change is a read-modify-write of one local item, so it runs
17
+ * under a Web Lock the extension's pages and worker share: two refused
18
+ * writes at once keep both changes, and a retry can't put an older value
19
+ * back over a newer one.
11
20
  *
12
21
  * Each extension supplies its schema: the defaults and one normaliser that
13
22
  * coerces anything read from storage or a form into valid settings.
@@ -72,6 +81,11 @@ export type SettingsStore<S extends Record<string, unknown>> = {
72
81
  * Try again to put earlier refusals into sync.
73
82
  */
74
83
  retryUnsynced: (storage: SyncedStorage) => Promise<boolean>;
84
+ /**
85
+ * Whether trying again could help: something held is still the newest and small enough for sync
86
+ * (an item over sync's size limit stays on this computer however often it's tried).
87
+ */
88
+ retryable: (storage: SyncedStorage) => Promise<boolean>;
75
89
  /**
76
90
  * Save the settings `changes` names, normalised.
77
91
  */
@@ -116,6 +130,9 @@ export type SettingsStore<S extends Record<string, unknown>> = {
116
130
  * everything reached sync.
117
131
  * @property {(storage: SyncedStorage) => Promise<boolean>} retryUnsynced
118
132
  * Try again to put earlier refusals into sync.
133
+ * @property {(storage: SyncedStorage) => Promise<boolean>} retryable
134
+ * Whether trying again could help: something held is still the newest and small enough for sync
135
+ * (an item over sync's size limit stays on this computer however often it's tried).
119
136
  * @property {(storage: SyncedStorage, changes: Partial<S>) => Promise<{settings: S, synced: boolean}>} save
120
137
  * Save the settings `changes` names, normalised.
121
138
  * @property {(changes: Record<string, StorageChange>, area: string) => (keyof S & string)[]} changed
@@ -8,6 +8,15 @@
8
8
  * `chrome.storage.local` instead. Local also holds any write sync refused
9
9
  * (too many writes a minute, or an item over its size limit), kept under
10
10
  * `unsyncedKey`, read as if synced, and retried, so a change is never lost.
11
+ * Beside it (`<unsyncedKey>Was`) is what sync held for each when it was
12
+ * refused: once sync holds something else (the setting was changed since on
13
+ * another computer), the held change is the older one and is dropped, not
14
+ * read over the newer one or sent up over it.
15
+ *
16
+ * Holding a change is a read-modify-write of one local item, so it runs
17
+ * under a Web Lock the extension's pages and worker share: two refused
18
+ * writes at once keep both changes, and a retry can't put an older value
19
+ * back over a newer one.
11
20
  *
12
21
  * Each extension supplies its schema: the defaults and one normaliser that
13
22
  * coerces anything read from storage or a form into valid settings.
@@ -15,7 +24,7 @@
15
24
  * @module settings/store
16
25
  */
17
26
 
18
- import { MAX_SYNC_ITEM_BYTES, isEmpty, pick, syncItemBytes } from './storage.js';
27
+ import { MAX_SYNC_ITEM_BYTES, exclusive, isEmpty, pick, sameValue, syncItemBytes } from './storage.js';
19
28
 
20
29
  /** @import { StorageChange, SyncedStorage } from './storage.js' */
21
30
 
@@ -51,6 +60,9 @@ import { MAX_SYNC_ITEM_BYTES, isEmpty, pick, syncItemBytes } from './storage.js'
51
60
  * everything reached sync.
52
61
  * @property {(storage: SyncedStorage) => Promise<boolean>} retryUnsynced
53
62
  * Try again to put earlier refusals into sync.
63
+ * @property {(storage: SyncedStorage) => Promise<boolean>} retryable
64
+ * Whether trying again could help: something held is still the newest and small enough for sync
65
+ * (an item over sync's size limit stays on this computer however often it's tried).
54
66
  * @property {(storage: SyncedStorage, changes: Partial<S>) => Promise<{settings: S, synced: boolean}>} save
55
67
  * Save the settings `changes` names, normalised.
56
68
  * @property {(changes: Record<string, StorageChange>, area: string) => (keyof S & string)[]} changed
@@ -78,9 +90,36 @@ export function createSettingsStore({ defaults, normalize: normalizeRaw, localKe
78
90
  /** @param {Record<string, unknown> | null} [raw] @returns {S} */
79
91
  const normalize = (raw) => normalizeRaw(raw ?? {});
80
92
 
93
+ /** The local item holding what sync held for each refused setting: `{key: [value]}`, `[]` for nothing. */
94
+ const wasKey = `${unsyncedKey}Was`;
95
+
96
+ /**
97
+ * What an area holds for a key, in a form that tells nothing from `null`.
98
+ *
99
+ * @param {Record<string, unknown>} items - What a `get` gave.
100
+ * @param {string} key - The key.
101
+ * @returns {unknown[]} `[value]`, or `[]` when it holds nothing.
102
+ */
103
+ const seen = (items, key) => (key in items ? [items[key]] : []);
104
+
105
+ /**
106
+ * The held changes that are still the newest: sync holds what it did when each was refused.
107
+ * One held before `wasKey` existed has nothing to compare, and counts as newest.
108
+ *
109
+ * @param {Record<string, unknown>} held - The held changes.
110
+ * @param {Record<string, unknown[]>} was - What sync held when each was refused.
111
+ * @param {Record<string, unknown>} synced - What sync holds now.
112
+ * @returns {Record<string, unknown>} The ones to keep.
113
+ */
114
+ const newest = (held, was, synced) => Object.fromEntries(Object.entries(pick(held, syncedKeys))
115
+ .filter(([key]) => !(key in was) || sameValue(was[key], seen(synced, key))));
116
+
81
117
  /**
82
118
  * Start one sync write of `values` at once; resolves to what sync refused.
83
- * An item too big for sync isn't even tried.
119
+ * An item too big for sync isn't even tried. When Chrome refuses several
120
+ * together for anything but its write rate, each is tried on its own, so
121
+ * one item Chrome won't take (the whole of sync is full, or it weighs more
122
+ * there than here) doesn't keep the others on this computer.
84
123
  *
85
124
  * @param {SyncedStorage} storage
86
125
  * @param {Record<string, unknown>} values
@@ -91,10 +130,13 @@ export function createSettingsStore({ defaults, normalize: normalizeRaw, localKe
91
130
  const fits = Object.fromEntries(entries.filter(([key, value]) => syncItemBytes(key, value) <= MAX_SYNC_ITEM_BYTES));
92
131
  const refused = Object.fromEntries(entries.filter(([key]) => !(key in fits)));
93
132
  if (isEmpty(fits)) return Promise.resolve(refused);
94
- return storage.sync.set(fits).then(() => refused, (/** @type {unknown} */ error) => {
95
- console.warn('Settings: Chrome Sync refused the change; keeping it on this computer and trying again later.',
96
- /** @type {Error | undefined} */ (error)?.message ?? error);
97
- return { ...refused, ...fits };
133
+ return storage.sync.set(fits).then(() => refused, async (/** @type {unknown} */ error) => {
134
+ const message = String(/** @type {Error | undefined} */ (error)?.message ?? error);
135
+ console.warn('Settings: Chrome Sync refused the change; keeping it on this computer and trying again later.', message);
136
+ const keys = Object.keys(fits);
137
+ if (keys.length === 1 || /MAX_WRITE_OPERATIONS/.test(message)) return { ...refused, ...fits };
138
+ const alone = await Promise.all(keys.map((key) => storage.sync.set({ [key]: fits[key] }).then(() => [], () => [key])));
139
+ return { ...refused, ...pick(fits, alone.flat()) };
98
140
  });
99
141
  }
100
142
 
@@ -102,30 +144,54 @@ export function createSettingsStore({ defaults, normalize: normalizeRaw, localKe
102
144
  async function load(storage) {
103
145
  const [synced, local] = await Promise.all([
104
146
  storage.sync.get([...syncedKeys]),
105
- storage.local.get([...localKeys, unsyncedKey]),
147
+ storage.local.get([...localKeys, unsyncedKey, wasKey]),
106
148
  ]);
107
- const { [unsyncedKey]: unsynced = {}, ...here } = local;
108
- return normalize({ ...synced, ...pick(unsynced, syncedKeys), ...here });
149
+ const { [unsyncedKey]: unsynced = {}, [wasKey]: was = {}, ...here } = local;
150
+ return normalize({ ...synced, ...newest(unsynced, was, synced), ...here });
109
151
  }
110
152
 
111
153
  /**
112
154
  * One sync write for all of `values` (sync allows 120 a minute), then any
113
- * that sync refused before. What sync refuses is kept in local, where
114
- * `load` still reads it, and goes up with the next write or
115
- * `retryUnsynced`.
155
+ * that sync refused before and that are still the newest. What sync refuses
156
+ * is kept in local, where `load` still reads it, and goes up with the next
157
+ * write or `retryUnsynced`.
116
158
  *
117
159
  * @type {SettingsStore<S>['writeSynced']}
118
160
  */
119
- async function writeSynced(storage, values) {
161
+ function writeSynced(storage, values) {
120
162
  // Sent before anything is awaited, so a page that's closing still gets it out.
121
163
  const sending = send(storage, values);
122
- const { [unsyncedKey]: held } = await storage.local.get([unsyncedKey]);
123
- let refused = await sending;
124
- const older = Object.fromEntries(Object.entries(pick(held ?? {}, syncedKeys)).filter(([key]) => !(key in values)));
125
- if (!isEmpty(older)) refused = { ...(await send(storage, older)), ...refused };
126
- if (!isEmpty(refused)) await storage.local.set({ [unsyncedKey]: refused });
127
- else if (held) await storage.local.remove(unsyncedKey);
128
- return isEmpty(refused);
164
+ return exclusive(`settings:${unsyncedKey}`, async (waited) => {
165
+ let refused = await sending;
166
+ const sent = Object.keys(values).filter((key) => !(key in refused));
167
+ if (waited && sent.length) {
168
+ // The work waited for may have been a retry that sent an older value after this one: put this one back.
169
+ const now = await storage.sync.get(sent);
170
+ const undone = pick(values, sent.filter((key) => !sameValue(seen(now, key), [values[key]])));
171
+ if (!isEmpty(undone)) refused = { ...refused, ...(await send(storage, undone)) };
172
+ }
173
+ const { [unsyncedKey]: held = {}, [wasKey]: was = {} } = await storage.local.get([unsyncedKey, wasKey]);
174
+ const heldKeys = Object.keys(pick(held, syncedKeys)).filter((key) => !(key in values));
175
+ const synced = await storage.sync.get([...heldKeys, ...Object.keys(refused)]);
176
+ const older = pick(newest(held, was, synced), heldKeys);
177
+ const stillOlder = isEmpty(older) ? {} : await send(storage, older);
178
+ const keep = { ...stillOlder, ...refused };
179
+ const keepWas = Object.fromEntries(Object.keys(keep).map((key) => [key, key in refused || !(key in was) ? seen(synced, key) : was[key]]));
180
+ if (isEmpty(keep)) {
181
+ if (!isEmpty(held) || !isEmpty(was)) await storage.local.remove([unsyncedKey, wasKey]);
182
+ } else if (!sameValue(keep, held) || !sameValue(keepWas, was)) {
183
+ await storage.local.set({ [unsyncedKey]: keep, [wasKey]: keepWas });
184
+ }
185
+ return isEmpty(keep);
186
+ });
187
+ }
188
+
189
+ /** @type {SettingsStore<S>['retryable']} */
190
+ async function retryable(storage) {
191
+ const { [unsyncedKey]: held = {}, [wasKey]: was = {} } = await storage.local.get([unsyncedKey, wasKey]);
192
+ const keys = Object.keys(pick(held, syncedKeys));
193
+ if (!keys.length) return false;
194
+ return Object.entries(newest(held, was, await storage.sync.get(keys))).some(([key, value]) => syncItemBytes(key, value) <= MAX_SYNC_ITEM_BYTES);
129
195
  }
130
196
 
131
197
  /**
@@ -166,6 +232,7 @@ export function createSettingsStore({ defaults, normalize: normalizeRaw, localKe
166
232
  normalize,
167
233
  load,
168
234
  writeSynced,
235
+ retryable,
169
236
  retryUnsynced: (/** @type {SyncedStorage} */ storage) => writeSynced(storage, {}),
170
237
  save,
171
238
  changed,
@@ -7,7 +7,11 @@
7
7
  * taken out or edited. Pure.
8
8
  *
9
9
  * A site is a web host: http or https, with a dot in its name (or
10
- * `localhost`). Text without a scheme is read as https.
10
+ * `localhost`). Text without a scheme is read as https. Only a real host
11
+ * name is a site: a `*` typed inside one would make a match pattern Chrome
12
+ * refuses (`*://*.*.example.com/*`), and with it the whole
13
+ * `registerContentScripts` call, so the extension would stop following its
14
+ * settings (checked in Chromium 156: "Invalid host wildcard").
11
15
  *
12
16
  * @module sites/sites
13
17
  */
@@ -35,6 +39,7 @@ export declare function siteOrigin(input: unknown): string | null;
35
39
  * @example
36
40
  * siteHost(' https://www.GitHub.com/a '); // 'github.com'
37
41
  * siteHost('news.bbc.co.uk'); // 'news.bbc.co.uk'
42
+ * siteHost('*.example.com'); // 'example.com': a list covers subdomains anyway
38
43
  * siteHost('not a site'); // null
39
44
  */
40
45
  export declare function siteHost(input: unknown): string | null;
@@ -7,7 +7,11 @@
7
7
  * taken out or edited. Pure.
8
8
  *
9
9
  * A site is a web host: http or https, with a dot in its name (or
10
- * `localhost`). Text without a scheme is read as https.
10
+ * `localhost`). Text without a scheme is read as https. Only a real host
11
+ * name is a site: a `*` typed inside one would make a match pattern Chrome
12
+ * refuses (`*://*.*.example.com/*`), and with it the whole
13
+ * `registerContentScripts` call, so the extension would stop following its
14
+ * settings (checked in Chromium 156: "Invalid host wildcard").
11
15
  *
12
16
  * @module sites/sites
13
17
  */
@@ -18,19 +22,31 @@ export const MAX_HOST_LENGTH = 253;
18
22
  /** A scheme at the start of what was typed; `localhost:8080` is a host and port, not one. */
19
23
  const SCHEME = /^[a-z][a-z\d+.-]*:(?!\d)/i;
20
24
 
25
+ /** A match pattern's wildcards at the start of what was typed: `*://` for any scheme, `*.` for subdomains. A listed site covers its subdomains anyway. */
26
+ const PATTERN_START = /^(?:\*:\/\/)?((?:[a-z][a-z\d+.-]*:\/\/)?)\*\./i;
27
+
28
+ /**
29
+ * A host name as the URL parser gives it (lower case, punycode), and nothing else it lets through:
30
+ * labels of letters, digits, `-` and `_`, none empty, a dot at the end allowed. `*`, commas and
31
+ * empty labels pass the parser, and a `*` goes on to make a match pattern Chrome refuses.
32
+ */
33
+ const HOST_NAME = /^[a-z\d_](?:[a-z\d_-]*[a-z\d_])?(?:\.[a-z\d_](?:[a-z\d_-]*[a-z\d_])?)*\.?$/;
34
+
21
35
  /**
22
- * What was typed (or stored, or a page's address) as a web URL.
36
+ * What was typed (or stored, or a page's address) as a web URL. A match pattern's wildcards at
37
+ * the start (`*.example.com`, `*://*.example.com/*`) are dropped: the site is `example.com`.
23
38
  *
24
39
  * @param {unknown} input - Anything.
25
40
  * @returns {URL | null} The URL, or null when it isn't a web site's.
26
41
  */
27
42
  function webUrl(input) {
28
43
  // Only text: a stray number would otherwise read as an IP address (3 → "0.0.0.3").
29
- const text = typeof input === 'string' ? input.trim() : '';
44
+ const text = typeof input === 'string' ? input.trim().replace(PATTERN_START, '$1').replace(/^\*:\/\//, '') : '';
30
45
  if (!text || /\s/.test(text)) return null;
31
46
  try {
32
47
  const url = new URL(SCHEME.test(text) ? text : `https://${text}`);
33
48
  if (url.protocol !== 'https:' && url.protocol !== 'http:') return null;
49
+ if (!HOST_NAME.test(url.hostname)) return null;
34
50
  return url.hostname.includes('.') || url.hostname === 'localhost' ? url : null;
35
51
  } catch {
36
52
  return null;
@@ -63,6 +79,7 @@ export function siteOrigin(input) {
63
79
  * @example
64
80
  * siteHost(' https://www.GitHub.com/a '); // 'github.com'
65
81
  * siteHost('news.bbc.co.uk'); // 'news.bbc.co.uk'
82
+ * siteHost('*.example.com'); // 'example.com': a list covers subdomains anyway
66
83
  * siteHost('not a site'); // null
67
84
  */
68
85
  export function siteHost(input) {