@arraypress/extension-kit 1.9.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +145 -18
  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/index.d.ts +2 -1
  9. package/src/build/index.js +2 -1
  10. package/src/build/store-shots.d.ts +177 -0
  11. package/src/build/store-shots.js +173 -0
  12. package/src/build/test-build.d.ts +13 -1
  13. package/src/build/test-build.js +41 -2
  14. package/src/build/vendor.d.ts +29 -8
  15. package/src/build/vendor.js +136 -40
  16. package/src/content/elements.d.ts +50 -0
  17. package/src/content/elements.js +88 -0
  18. package/src/content/highlight.d.ts +63 -0
  19. package/src/content/highlight.js +117 -0
  20. package/src/content/overlay.d.ts +31 -6
  21. package/src/content/overlay.js +56 -8
  22. package/src/content/page-toast.d.ts +26 -0
  23. package/src/content/page-toast.js +72 -0
  24. package/src/content/slices.d.ts +3 -1
  25. package/src/content/slices.js +29 -14
  26. package/src/content/watch.d.ts +4 -2
  27. package/src/content/watch.js +17 -3
  28. package/src/dom/copy.d.ts +36 -0
  29. package/src/dom/copy.js +84 -0
  30. package/src/dom/focus.d.ts +11 -0
  31. package/src/dom/focus.js +15 -0
  32. package/src/dom/i18n.d.ts +17 -0
  33. package/src/dom/i18n.js +15 -0
  34. package/src/dom/make.d.ts +53 -0
  35. package/src/dom/make.js +71 -0
  36. package/src/frame/window.d.ts +8 -1
  37. package/src/frame/window.js +11 -8
  38. package/src/index.d.ts +31 -6
  39. package/src/index.js +24 -6
  40. package/src/panel/show-on-page.d.ts +82 -0
  41. package/src/panel/show-on-page.js +83 -0
  42. package/src/scripting/call.d.ts +29 -9
  43. package/src/scripting/call.js +35 -11
  44. package/src/scripting/context-menus.d.ts +45 -0
  45. package/src/scripting/context-menus.js +53 -0
  46. package/src/scripting/registration.d.ts +2 -1
  47. package/src/scripting/registration.js +2 -1
  48. package/src/scripting/tab.d.ts +80 -0
  49. package/src/scripting/tab.js +89 -0
  50. package/src/settings/saver.js +16 -6
  51. package/src/settings/storage.d.ts +33 -2
  52. package/src/settings/storage.js +73 -3
  53. package/src/settings/store.d.ts +17 -0
  54. package/src/settings/store.js +87 -20
  55. package/src/sites/sites.d.ts +6 -1
  56. package/src/sites/sites.js +20 -3
  57. package/src/styles/chrome-ui.css +63 -30
  58. package/src/testing/e2e.d.ts +35 -4
  59. package/src/testing/e2e.js +67 -5
  60. package/src/testing/fake-model.d.ts +9 -1
  61. package/src/testing/fake-model.js +44 -23
  62. package/src/testing/fake-storage.d.ts +41 -13
  63. package/src/testing/fake-storage.js +163 -51
  64. package/src/testing/index.d.ts +8 -2
  65. package/src/testing/index.js +8 -2
  66. package/src/testing/live-run.d.ts +151 -0
  67. package/src/testing/live-run.js +206 -0
  68. package/src/testing/messages.d.ts +7 -3
  69. package/src/testing/messages.js +50 -9
  70. package/src/testing/package.d.ts +56 -7
  71. package/src/testing/package.js +57 -18
  72. package/src/testing/pages.d.ts +114 -0
  73. package/src/testing/pages.js +109 -0
  74. package/src/testing/prompt.d.ts +4 -2
  75. package/src/testing/prompt.js +7 -3
  76. package/src/time/pool.d.ts +2 -1
  77. package/src/time/pool.js +4 -2
  78. package/src/toolbar/theme.d.ts +10 -2
  79. package/src/toolbar/theme.js +12 -4
  80. package/src/ui/menu.d.ts +13 -3
  81. package/src/ui/menu.js +31 -11
  82. package/src/ui/model-row.js +5 -1
  83. package/src/ui/notice.d.ts +58 -0
  84. package/src/ui/notice.js +43 -0
  85. package/src/ui/site-list.js +12 -3
  86. package/src/ui/text-dialog.d.ts +3 -2
  87. package/src/ui/text-dialog.js +10 -3
  88. package/src/ui/toast.js +2 -0
package/README.md CHANGED
@@ -74,9 +74,12 @@ Other libraries are copied the same way, by name (a path ending in `/` copies a
74
74
  ```
75
75
 
76
76
  `npm install some-package` doesn't run the extension's own `postinstall`, so a copy can go stale
77
- without a word. Each copy is stamped with its package's version (`vendored.json`, left out of the
78
- zip), and `extension-kit vendor-check` fails, naming the copy, until `npm run postinstall` remakes
79
- it. From a script: `vendorPackage(name, files, dest)`, `checkVendored(dir)`.
77
+ without a word. Each copy is stamped with its package's version, what was copied and a hash of it
78
+ (`vendored.json`, left out of the zip), and `extension-kit vendor-check` fails, naming the copy,
79
+ until `npm run postinstall` remakes it: a copy of another version, one edited by hand, one whose
80
+ package's files changed under the same version (a library copied into `node_modules` to try it
81
+ before it's published), or a vendor folder with no copies in it at all. A copy is never made into
82
+ a folder that holds a `manifest.json`, a `package.json` or a `.git` (the folder is emptied first). From a script: `vendorPackage(name, files, dest)`, `checkVendored(dir)`.
80
83
 
81
84
  ### An extension with a bundler
82
85
 
@@ -116,10 +119,12 @@ saver.follow((settings) => show(settings), chrome.storage.onChanged);
116
119
 
117
120
  | Store member | What it does |
118
121
  |---|---|
119
- | `load(storage)` | Every setting: sync, then held refusals, then local, normalised. |
122
+ | `load(storage)` | Every setting: sync, then held refusals (the ones still newer than what sync holds), then local, normalised. |
120
123
  | `save(storage, changes)` | Writes only the settings named, so another computer's change to a different one survives. Resolves to `{settings, synced}`. |
121
- | `writeSynced(storage, values)` | One sync write; what sync refuses (rate limit, an item over 8,192 bytes) is kept in local under `unsyncedKey` and retried with the next write. |
124
+ | `writeSynced(storage, values)` | One sync write; what sync refuses (rate limit, an item over 8,192 bytes) is kept in local under `unsyncedKey` and retried with the next write. Items refused together are tried one at a time, and a held change is dropped once another computer has changed that setting since. Holding runs under a Web Lock, so two writers at once lose nothing. |
122
125
  | `retryUnsynced(storage)` | Try the held refusals again. |
126
+ | `retryable(storage)` | Whether trying again could help (something held is still the newest and fits one sync item). |
127
+ | `syncItemBytes(key, value)` (an export) | What Chrome Sync counts for one item: the key plus the value as Chrome's JSON writer spells it (`<` is six bytes). |
123
128
  | `changed(changes, area)` | The settings a `chrome.storage.onChanged` event touches. |
124
129
  | `normalize(raw)`, `defaults`, `localKeys`, `syncedKeys`, `unsyncedKey` | The schema. |
125
130
 
@@ -245,16 +250,88 @@ for (const { frameId, result } of frames) show(frameId, result);
245
250
  // From the toolbar click: the tab's own side panel, panel/panel.html?tab=<id>, nothing awaited first.
246
251
  chrome.action.onClicked.addListener((tab) => openSidePanel(chrome.sidePanel, tab.id, 'panel/panel.html'));
247
252
 
248
- // In the content script: a layer the page's styles can't touch (a closed shadow root).
249
- const { root, remove } = pageOverlay(document, { name: 'my-inspector', css: ':host { position: fixed; inset: 0; pointer-events: none; }' });
253
+ // In the panel: every frame of the tab, or why it can't be read.
254
+ const reading = await callInTab(chrome, tabId, 'content/main.js', 'scan');
255
+ if (reading.state === 'read') show(reading.frames); // top frame first
256
+ else showMessage(reading.state); // 'browser' (Chrome's own page), 'no-access', 'failed' or 'timeout'
257
+
258
+ // In the content script: a layer the page's styles can't touch (a closed shadow root). The host's
259
+ // own styles go in hostStyle: its inline `all: initial !important` reset beats a `:host` rule.
260
+ const { root, remove } = pageOverlay(document, {
261
+ name: 'my-inspector',
262
+ hostStyle: { position: 'fixed', inset: '0', 'z-index': '2147483647', 'pointer-events': 'none' },
263
+ css: `${HIGHLIGHT_CSS} .card { position: absolute; }`,
264
+ });
265
+
266
+ // “Show on page”: boxes in DevTools' blue, kept on the elements as the page scrolls; Esc clears.
267
+ // onClear says why they went, so the panel hears of the ones it didn't ask for.
268
+ const highlight = createHighlighter(document, {
269
+ root: () => root,
270
+ onClear: (reason) => { if (reason === 'escape' || reason === 'pagehide') chrome.runtime.sendMessage({ type: 'boxes-cleared' }); },
271
+ });
272
+ highlight.show(elements, { reveal: true });
273
+
274
+ // In the panel: which item is shown, whatever ends it.
275
+ const onPage = createShowOnPage({ show: (ids, frame) => callPage('highlight', [ids, frame]), onChange: press });
276
+ button.addEventListener('click', () => onPage.toggle(font.id, [font.id], font.frameId));
277
+ chrome.runtime.onMessage.addListener((message) => { if (message.type === 'boxes-cleared') onPage.cleared(); });
278
+ addEventListener('pagehide', () => onPage.reset()); // and `await onPage.reset()` before a rescan
250
279
  ```
251
280
 
281
+ A page can load a web-accessible module by the extension's address, and so tell the extension
282
+ is installed. `"use_dynamic_url": true` stops that (the page's `import()` and `fetch()` are
283
+ refused) and `callInPage`'s own import still works, but **only for a module with no static
284
+ imports**: Chrome refuses a dynamic-URL module's `import … from './part.js'` (checked in Chromium
285
+ 156, `tests/chrome-chromium.test.js`). The family's page modules import their parts and the kit
286
+ statically and have no bundler, so they are listed without it; a single-file page module (a
287
+ bundled one) can use it.
288
+
252
289
  `callInPage(scripting, target, module, name, args, {immediately})` resolves to each frame's
253
290
  `{frameId, result}` (frames Chrome couldn't run in left out) and rejects when it can run in none;
254
291
  `immediately` (default true) doesn't wait for the page to be idle. `openSidePanel` sets the tab's
255
292
  panel to `path?tab=<id>` (`sidePanelPath`) and opens it in the same moment: Chrome opens a panel
256
- only while the click counts. `pageOverlay` puts a host element of its own name (`all: initial`)
257
- on the root element, replacing one a run before left; `root` is its closed shadow root, `css` first.
293
+ only while the click counts. `callInTab(edges, tabId, module, name, args)` calls it in every frame
294
+ and gives `{state: 'read', frames}` (frames that answered, top first), or `{state: 'browser'}` for
295
+ a page Chrome keeps to itself (`isBrowserPage(url)`: its own pages, the Web Store) or
296
+ `{state: 'no-access'}` (the tab went to another site since the click), `{state: 'failed', error}`
297
+ (the page module didn't load or threw in every frame; Chrome alone reports that as null) or, with
298
+ `{timeoutMs}`, `{state: 'timeout'}` (Chrome answers only once every frame has). `pageOverlay` puts a host
299
+ element of its own name on the root element, replacing one a run before left; `root` is its closed
300
+ shadow root, `css` first. The host is reset inline (`all: initial !important`) so the page can't
301
+ move or hide it; that also beats a `:host { … }` rule and `host.style.x = …`, so the host's own
302
+ styles go in `hostStyle` (set `!important` after the reset). `topLayer: true` makes the host a
303
+ manual popover, drawn over the page's modal dialogs and fullscreen element and put back on top
304
+ (`raise()`) when the page opens one after it; a modal dialog still makes it inert, so it shows but
305
+ can't be clicked while the dialog is open. `createHighlighter(doc, {root, onClear,
306
+ max})` boxes up to 500 elements (`MAX_BOXES`) in the overlay (`HIGHLIGHT_CSS` styles them), only
307
+ those in the window, following the page as it scrolls; `show` replaces the set, `clear`, Esc or
308
+ leaving the page ends it, and `onClear(reason)` hears which (`'replaced'`, `'asked'`, `'escape'`,
309
+ `'pagehide'`). `createShowOnPage({show, onChange})` is the panel's side: `toggle`, `reset`, `cleared`.
310
+
311
+ ```js
312
+ // A page's elements in a line, and copying from a click (the copy command where the API can't).
313
+ row.append(make('span', 'meta', '12 colours'), iconButton({ icon: 'i-copy', label: t('copy'), onClick: () => copyFormats(document, { text: hex }) }));
314
+ ```
315
+
316
+ `make(tag, className, text, doc)`, `spriteIcon(id, className, doc)` (a `<use>` of the page's
317
+ sprite) and `iconButton({icon, label, onClick, className, doc})` (tooltip and label) take the
318
+ document the element belongs to, so a content script's overlay uses them too.
319
+ `copyFormats(doc, {text, html})` puts plain text and HTML on the clipboard at once (a titled link
320
+ for rich editors, its plain text for the rest) and resolves to whether it did; a content script
321
+ can call it from a menu click or a shortcut (Chrome lets the focused active tab write), and
322
+ `showPageToast(doc, 'Link copied', {name})` confirms it with Chrome's own toast in the page.
323
+ `noticeCard({icon, tone, title, text, buttons, actions})` is a side panel's notice (`.sp-notice`:
324
+ frames from other sites to allow); `counted(count, one, many, {t, lang})` a count in words.
325
+
326
+ ## The right-click menu
327
+
328
+ ```js
329
+ // Built on install and on browser start; after a Chrome update both come in one run, and two
330
+ // builds side by side would create every item twice. Through the builder, one waits for the other.
331
+ const buildMenu = contextMenuBuilder(chrome);
332
+ chrome.runtime.onInstalled.addListener(() => buildMenu(menuItems(t)));
333
+ chrome.runtime.onStartup.addListener(() => buildMenu(menuItems(t)));
334
+ ```
258
335
 
259
336
  ## A content script on every site
260
337
 
@@ -296,6 +373,34 @@ first session after Chrome starts can take ~18 s while the model loads: a questi
296
373
  limit comes back `'late'`, and the session goes on loading for the next one. Nothing loads it ahead
297
374
  of time (that would load the model on every start). `release()` lets the base session go.
298
375
 
376
+ A base session Chrome has let go since (the model was updated, its process ended) is made again
377
+ when its `clone()` fails.
378
+
379
+ **In a service worker.** Chrome's documentation says the Prompt API isn't in workers; Chrome 157
380
+ and Chromium 156 do have `LanguageModel` in an extension's service worker (checked in both, with
381
+ an unpacked extension: `'function'` in the worker, an extension page and an offscreen document).
382
+ Since neither is promised, a worker uses `createHostedModelSession`: the same `ModelSession`,
383
+ asking the worker's own `LanguageModel` when there is one and otherwise the extension's offscreen
384
+ document, over `chrome.runtime` messages.
385
+
386
+ ```js
387
+ // background.js
388
+ const words = createHostedModelSession({
389
+ name: 'words', options: ENGLISH, initialPrompts: [{ role: 'system', content: 'You list words.' }],
390
+ runtime: chrome.runtime,
391
+ open: () => watchFromOffscreen(chrome, undefined, 'Reads light or dark mode for the toolbar icon, and runs Chrome’s on-device model.'),
392
+ });
393
+
394
+ // offscreen/offscreen.js: the one offscreen document an extension gets does both jobs.
395
+ watchSchemeOffscreen(chrome.runtime);
396
+ serveModelOffscreen(chrome.runtime);
397
+ ```
398
+
399
+ The document is told each session's options and instructions with the request, keeps one base
400
+ session per `name`, keeps the time limit, and stops an answer when the caller's signal aborts. It
401
+ ignores requests from content scripts and other extensions. The schema for an answer crosses as
402
+ JSON (an object, not a RegExp).
403
+
299
404
  `modelAvailability(api, options)` never throws (`'missing'` without a Prompt API).
300
405
  `downloadModel(api, options, {onProgress})` is the only `create()` that may download: call it
301
406
  first in a click's handler, with nothing awaited before it. The settings row:
@@ -347,9 +452,18 @@ hands the main thread back whenever a slice has run its time, so a 2,000-item li
347
452
  freezes. `eachInSlices(items, work, {sliceMs, pause})` does the same for any list (each element's
348
453
  style read, each text measured): `work(item, index)`. `watchChanges` hands over what was added or changed a little after the page stops
349
454
  changing (at most `maxWaitMs` while it keeps on); an element with a few children (`regroupChildren`,
350
- default 6) whose children changed is handed over whole. Both are pure DOM, so a content script
455
+ default 6) whose children changed is handed over whole; what changes inside the extension's own
456
+ elements (`ignore`, asked of a change's target and its ancestors) sets nothing off. `inSlices` cuts
457
+ the page as the work goes and leaves out scripts, styles and templates. Both are pure DOM, so a content script
351
458
  imports them directly (list them in `web_accessible_resources`).
352
459
 
460
+ `renderedElements(doc, {skip, limit})` (`content/elements.js`) lists every element Chrome draws,
461
+ in document order, into open and closed shadow roots (`shadowRootOf`, through
462
+ `chrome.dom.openOrClosedShadowRoot`) and through `display: contents` wrappers; what's inside a
463
+ `visibility: hidden` element is still read, nothing inside `display: none`; at most 20,000
464
+ (`MAX_ELEMENTS`). `otherSiteFrames(doc)` gives the origins of frames from other sites, which a
465
+ reading of the tab leaves out until the person allows those sites.
466
+
353
467
  ## Time limits
354
468
 
355
469
  ```js
@@ -394,7 +508,10 @@ It opens modally over its button, as Chrome's does (its top at the button's top,
394
508
  button's end), moved to stay 8px inside the window. A click outside, Esc, Tab or the window losing
395
509
  focus closes it; ↑/↓ (wrapping, past disabled items), Home and End move; focus goes back to the
396
510
  button (or `focusTo`); an item runs once the menu has closed. `createMenu({id, placement: 'below'})`
397
- opens it below the button instead. `menuPosition` and `isInside` are the pure geometry.
511
+ opens it below the button instead. `menuPosition` and `isInside` are the pure geometry. In a
512
+ content script, give it the overlay's shadow root (`createMenu({root})`, a closed one too): the
513
+ keys it handles don't reach the page's own shortcuts, and `destroy()` takes it off the page with
514
+ its window listener. On a right-to-left page it lines up with its button's other edge.
398
515
 
399
516
  `moreActionsButton(name, options)` is the 32px ⋮ icon button, labelled "More actions for “name”"
400
517
  (`label` for a translation), with `aria-haspopup="menu"`; its icon is the stylesheet's
@@ -513,9 +630,10 @@ A list with an Add button needs every key but `optionsRemoveLabel`; one with an
513
630
  | `vendorPackage(name, files, dest)`, `packageDir(name)` | Copy files of an installed package, stamped (`extension-kit vendor <dir> <package> <path>...`). |
514
631
  | `checkVendored(dir)` | The stale copies under `dir` (`extension-kit vendor-check`). |
515
632
  | `renderIcons`, `renderToolbarIcons({chromium, dir})` | See above. |
516
- | `buildTestCopy({from, to, hook, manifest})` | A test build: the extension, `hook` as its service worker (`e2e-hook.js`), the manifest changed. |
633
+ | `buildTestCopy({from, to, hook, manifest, panelOpener})` | A test build: the extension, `hook` as its service worker (`e2e-hook.js`), the manifest changed; with `panelOpener` (`'panel/panel.html'`), `e2e/open-panel.html?tab=<id>` (`PANEL_OPENER`), whose button opens that side panel beside the tab as the toolbar click does (Playwright can't click the toolbar; its title turns `opened`). |
517
634
  | `testBuildDir(root, name)` | `.test-builds/<name>`, or under `$EXTENSION_TEST_BUILDS` (to test another build, or run two side by side). |
518
635
  | `storeFrame` | Store screenshots: `canvas`, `windowChrome` (and `windowChromeSvg`, from `frame/window.js`), `geometry`, `letterIcon`, `cursor`, `THEMES`, `W`×`H`. |
636
+ | `storeShots({browser, dir, toolbarIcon})`, `storeShotHtml(shot)`, `contextMenuHtml(menu)`, `pngUrl(png)`, `PANEL_W`, `PANEL_HEADER_H` | The family's store screenshots in one style: a caption in sentence case over Chrome's light window with the extension pinned in the toolbar, real captures (taken at 2×) inside; what headless Chrome can't capture composed around the real extension UI: the popup page hung from the pressed toolbar button, the side panel page beside the page under a header with the extension's name, the right-click menu drawn as macOS draws Chrome's (the extension's real titles). `shoot(file, shot)` writes a 1280×800 PNG. |
519
637
  | `placeholderWarning`, `repositoryReminder`, `printReleaseChecks` | See About links. |
520
638
  | `findDisallowedClones({runJscpd})` | Run jscpd and return clones `.jscpd-allowlist.json` doesn't cover (the `extension-kit duplication` command). |
521
639
 
@@ -532,33 +650,42 @@ except those listed, each with a reason:
532
650
 
533
651
  | Export | What it does |
534
652
  |---|---|
535
- | `fakeLanguageModel(state)` | A fake Prompt API (`LanguageModel`): `availability`, `create` (download progress, a cold start, `failDownload`), sessions that `clone`, answer (`answerMs`, `answer(prompt)`, `failPrompt`) and stop on their signal; `log` of what it was asked, `set` to change it. Browser-safe, so a test build can put it on `globalThis`. |
536
- | `fakeChromeStorage({local, sync, session, now})` | In-memory `chrome.storage`: JSON copies, `onChanged` for real changes only, sync's 8,192-byte item quota, its 102,400 bytes in all (`SYNC_QUOTA_BYTES`) and 120 writes a minute. `settled()` waits for listeners. |
653
+ | `fakeLanguageModel(state)` | A fake Prompt API (`LanguageModel`): `availability`, `create` (download progress, a cold start, `failDownload`), sessions that `clone`, answer (`answerMs`, `answer(prompt)`, `failPrompt`) and stop on their signal, a destroyed session that answers nothing more, a download refused without a click (`userActivation`); `log` of what it was asked, `set` to change it. Browser-safe, so a test build can put it on `globalThis`. |
654
+ | `fakeChromeStorage({local, sync, session, now})` | In-memory `chrome.storage`, each point checked against Chromium: values as Chrome stores them (`undefined`, functions and `NaN` dropped, a `Date` is `{}`), `get` with a key, keys, defaults or nothing, `clear`, `getBytesInUse`, `onChanged` (the area's own too, `removeListener`) for real changes only, whatever order an object's keys come in. Sync's quotas with Chrome's own errors: 8,192 bytes an item counted as Chrome counts them (`syncItemBytes`), 102,400 bytes (`SYNC_QUOTA_BYTES`) and 512 items (`SYNC_MAX_ITEMS`) in all, 120 calls a minute and 1,800 an hour (`SYNC_WRITES_PER_MINUTE`, `SYNC_WRITES_PER_HOUR`) of each of `set`, `remove` and `clear`; local and session hold 10 MB (`LOCAL_QUOTA_BYTES`). `settled()` waits for listeners. |
537
655
  | `launchExtension(chromium, dir, options)` | Load an unpacked extension into a fresh profile; `{context, worker, extensionId, url(page), profile, close()}`. |
538
656
  | `launchDownloading(chromium, dir, options)`, `filesIn(dir)` | An extension in a browser that downloads for real: `{…launchExtension's, downloads}`, the profile's own Downloads folder (Playwright otherwise saves downloads under made-up names no extension sees); the files under a folder, downloads still running left out. |
539
657
  | `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()}`. |
540
658
  | `runChecks(runner, body, cleanUps)` | A script's checks: an error outside any check fails, the clean-ups always run (each within 30 s), then the tally. |
541
- | `messageLookup(extensionDir, locale)`, `readMessages(extensionDir, locale)` | `chrome.i18n.getMessage` over the real `_locales` (a locale over en, named placeholders filled), for unit tests. |
659
+ | `messageLookup(extensionDir, locale)`, `readMessages(extensionDir, locale)` | `chrome.i18n.getMessage` over the real `_locales`, as Chromium answers: a locale over en, names in any case, named placeholders and `$1`–`$9` filled, `$$` a dollar sign; a message naming a placeholder it doesn't define throws (Chrome won't load that extension). For unit tests. |
542
660
  | `heartbeat(page)` | A timer beating in a page: `stop()` gives the longest the main thread was held (ms), for performance checks. |
543
- | `playPermissionPrompt(answers)` | Chrome's permission prompt played in a page (`page.evaluate` or `addInitScript`): each request recorded on `window.asked` with whether a click was behind it, answered from `answers`. |
544
- | `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. |
661
+ | `playPermissionPrompt(answers)` | Chrome's permission prompt played in a page (`page.evaluate` or `addInitScript`): each request recorded on `window.asked` with whether a click was behind it, answered from `answers`; one with no click behind it is refused, as Chrome refuses it. |
662
+ | `checkRunner({timeout})` | Named checks that report and go on: `check(name, fn, {timeout})`, `fail(name)` for an error found outside a check, `finish()` (exits 1 on a failure, or when no check ran at all). A check that runs past its limit (`CHECK_MS`, 60 s) fails, and the run goes on. |
545
663
  | `eachLimited(items, limit, work)` | Work through a list at most `limit` at once, `work(item, slot, index)` (also in the main entry). |
546
664
  | `realisticBrowser(options)`, `chromeUserAgent(browser)` | Launch options and a user agent for a browser that doesn't say it's automated, for live checks. |
547
665
  | `closeOnSignal(close)` | On Ctrl-C or a kill, close the run's browsers, then exit. |
548
666
  | `testBuildDir(root, name)` | As in `/build`, for the tests that load the build. |
667
+ | `tabIdOf(worker, page)` | A Playwright page's tab id, as the extension's worker sees it. |
668
+ | `callTabModule(worker, tabId, module, name, args)` | An export of the extension's page module called in a tab's frames, as `callInPage` does: `[{frameId, result}]`. |
669
+ | `pierceNodes(cdp)`, `nodeAttribute(node, name)`, `nodeText(node)` | A page's whole tree through the DevTools protocol, closed shadow roots and frames included (where an extension's overlay is), and a node's attribute and text. |
549
670
  | `serve(routes, {port, tls})` | Serve test pages on 127.0.0.1 from a table, a `Map` or a function of the path; `{origin, port, server, close()}`. With `tls` (`testCertificate()`, a throwaway one from `openssl`), over HTTPS: for a site Chrome reaches only over HTTPS, mapped with `--host-resolver-rules`, Chromium started with `--ignore-certificate-errors`. |
550
671
  | `until(fn, what, timeout?)` | Poll until truthy. |
551
672
  | `offscreenEmulator(cdp)` | Emulate light/dark inside the offscreen document. |
552
673
  | `recordIconCalls(worker)` | Record `chrome.action.setIcon` calls Chrome accepted. |
674
+ | `stopWorker(launched, {start})` | Stop the service worker as Chrome does when it's idle (memory gone, storage kept) and start it again, or leave it for the next event: for checks that state survives a restart. Playwright's debugger otherwise keeps the worker alive for the whole run. |
553
675
  | `installWarnings(worker, manifest)` | Chrome's own install warnings for a manifest (`chrome.management.getPermissionWarningsByManifest`, no permission needed), from a loaded extension's worker. |
554
676
  | `warningsAddedBy(worker, manifest, names)` | `{warnings, without, added}`: the warnings with and without some permissions, and those only they bring (`withoutPermissions` is the pure part). |
555
677
  | `savedTabGroups(profile)` | Chrome's saved tab groups (`{title, pinned, urls}`) read from a closed profile's `Sync Data/LevelDB`: no extension API lists them. |
556
678
  | `parseLiveOptions(argv, env, {usage})`, `parseProxy(address)` | A live checker's command line (`--only`, `--category`, `--concurrency`, `--proxy` or `HTTPS_PROXY`, `--locale`, `--timezone`, `--out`, `--help`), and a proxy address for Playwright with its password hidden for the output. |
679
+ | `runLiveCheck({chromium, build, prefix, root, sites, categories, pageMs, visit, failed, onResult})`, `liveUsage(categories, n)` | A live checker's whole run: its command line (`parseLiveOptions`, usage from the categories), the sites chosen, the exit point printed, each site visited on a fresh page in a browser with the extension (one a slot, at most 4, passing for a person's), within `pageMs` (a browser whose page ran out of time replaced), Ctrl-C closing them all; `{dir, started, finished, exit, browser, results}` for the checker's report. A visit that throws, runs out of time or loses its browser is `failed(site, why)` and the run goes on with a new browser. The report folder is emptied only if the kit made it (`reportFolder`, `REPORT_MARKER`): `--out` on a folder with other files in it is refused. |
557
680
  | `exitPoint(request)` | Where the run's traffic leaves for the internet (`{ip, country, where, source}`), from a keyless lookup service through the context's proxy: sites decide what to show by it. |
558
- | `filesUnder`, `zipEntries`, `missingReferences`, `manifestReferences` | For a packaging test: the zip is the folder, and every path the extension names resolves (a `#fragment` or `?query` ignored: `referencedPath`). |
681
+ | `filesUnder`, `zipEntries`, `missingReferences`, `manifestReferences` | For a packaging test: the zip is the folder, and every path the extension names is a file the zip holds (one left out of the zip is as missing as one that isn't there): imports (`from`, `import()`, a bare `import './x.js'`), `getURL`, `src`/`href`, a stylesheet's `url()` and `@import`, and the manifest's worker, pages, icons, content scripts, default locale's messages, rule files and web-accessible resources. A `#fragment` or `?query` is ignored (`referencedPath`). |
559
682
 
560
683
  ## Stylesheet classes
561
684
 
685
+ The stylesheet uses logical properties (and mirrors the few things that need it: a select's arrow,
686
+ the switch's knob, the link-row arrow, the drawer), so a page with `dir="rtl"` reads as Chrome's
687
+ own pages do in a right-to-left language.
688
+
562
689
  | Piece | Markup |
563
690
  |---|---|
564
691
  | Page column (680px, 96%) | `<main class="page">` |
@@ -6,7 +6,8 @@
6
6
  * extension-kit vendor <dir> <package> <path>... copy files of an installed package into <dir>
7
7
  * (a path ending in / copies its contents)
8
8
  * extension-kit vendor-check [dir] fail when a copy in [dir] (default extension/vendor)
9
- * no longer matches the installed package
9
+ * no longer matches the installed package (another
10
+ * version, other files, edited since), or there's none
10
11
  * extension-kit icons [dir] render [dir]/icon.svg (default extension/icons)
11
12
  * extension-kit toolbar-icons [dir] render the toolbar glyph (default extension/icons/toolbar)
12
13
  * extension-kit duplication fail on copied code jscpd finds, less .jscpd-allowlist.json
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arraypress/extension-kit",
3
- "version": "1.9.0",
3
+ "version": "1.11.0",
4
4
  "description": "Shared pieces for Chrome extensions: Chrome's own page styles and components, a Chrome Sync settings store, the light/dark toolbar glyph, Chrome's on-device model, content-script helpers, About links, and build and test helpers.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -0,0 +1,183 @@
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
+ import type { InitialPrompt, LanguageModelOptions, LanguageModelStatic, ModelSession } from './model.js';
23
+ /** @import { AskOptions, Availability, InitialPrompt, LanguageModelOptions, LanguageModelStatic, ModelAnswer, ModelSession } from './model.js' */
24
+ /** The request's message type: `{type: MODEL_HOST, method, name, …}`. */
25
+ export declare const MODEL_HOST = "model-host";
26
+ /** How long past an answer's time limit the worker waits for the offscreen document to say so itself (ms). */
27
+ export declare const HOST_GRACE_MS = 1000;
28
+ export type HostRequest = {
29
+ /**
30
+ * The message type.
31
+ */
32
+ type: typeof MODEL_HOST;
33
+ /**
34
+ * What's asked.
35
+ */
36
+ method: 'availability' | 'ask' | 'abort' | 'release';
37
+ /**
38
+ * The session's name (one base session each).
39
+ */
40
+ name: string;
41
+ /**
42
+ * What its sessions are made with.
43
+ */
44
+ options?: LanguageModelOptions;
45
+ /**
46
+ * Its instructions and examples.
47
+ */
48
+ initialPrompts?: InitialPrompt[];
49
+ /**
50
+ * A question's id, for `abort`.
51
+ */
52
+ id?: string;
53
+ /**
54
+ * The question.
55
+ */
56
+ prompt?: string;
57
+ /**
58
+ * Its time limit.
59
+ */
60
+ timeoutMs?: number;
61
+ /**
62
+ * The answer's JSON Schema.
63
+ */
64
+ responseConstraint?: object;
65
+ };
66
+ export type HostSender = {
67
+ sendMessage(message: unknown): Promise<unknown>;
68
+ };
69
+ export type HostRuntime = {
70
+ /**
71
+ * This extension's id: requests from anyone else are ignored.
72
+ */
73
+ id: string;
74
+ /**
75
+ * `chrome.runtime.onMessage`.
76
+ */
77
+ onMessage: {
78
+ addListener(listener: (message: unknown, sender: {
79
+ id?: string;
80
+ tab?: unknown;
81
+ }, respond: (answer: unknown) => void) => boolean): void;
82
+ };
83
+ };
84
+ export type HostedSessionSetup = {
85
+ /**
86
+ * The session's name, its own among the extension's sessions ('topics').
87
+ */
88
+ name: string;
89
+ /**
90
+ * What sessions are made (and availability asked) with.
91
+ */
92
+ options: LanguageModelOptions;
93
+ /**
94
+ * The instructions and examples every question starts from.
95
+ */
96
+ initialPrompts?: InitialPrompt[];
97
+ /**
98
+ * `chrome.runtime`.
99
+ */
100
+ runtime: HostSender;
101
+ /**
102
+ * Makes sure the offscreen document is open
103
+ * (`() => watchFromOffscreen(chrome)`); only called when the worker has no Prompt API.
104
+ */
105
+ open: () => Promise<unknown>;
106
+ /**
107
+ * The worker's own Prompt API, read when used.
108
+ * Default Chrome's.
109
+ */
110
+ model?: () => LanguageModelStatic | undefined;
111
+ };
112
+ /**
113
+ * A request to the offscreen document.
114
+ *
115
+ * @typedef {object} HostRequest
116
+ * @property {typeof MODEL_HOST} type The message type.
117
+ * @property {'availability' | 'ask' | 'abort' | 'release'} method What's asked.
118
+ * @property {string} name The session's name (one base session each).
119
+ * @property {LanguageModelOptions} [options] What its sessions are made with.
120
+ * @property {InitialPrompt[]} [initialPrompts] Its instructions and examples.
121
+ * @property {string} [id] A question's id, for `abort`.
122
+ * @property {string} [prompt] The question.
123
+ * @property {number} [timeoutMs] Its time limit.
124
+ * @property {object} [responseConstraint] The answer's JSON Schema.
125
+ */
126
+ /**
127
+ * The parts of `chrome.runtime` the worker's side uses.
128
+ *
129
+ * @typedef {{sendMessage(message: unknown): Promise<unknown>}} HostSender
130
+ */
131
+ /**
132
+ * The parts of `chrome.runtime` the offscreen document's side uses.
133
+ *
134
+ * @typedef {object} HostRuntime
135
+ * @property {string} id This extension's id: requests from anyone else are ignored.
136
+ * @property {{addListener(listener: (message: unknown, sender: {id?: string, tab?: unknown}, respond: (answer: unknown) => void) => boolean): void}} onMessage
137
+ * `chrome.runtime.onMessage`.
138
+ */
139
+ /**
140
+ * @typedef {object} HostedSessionSetup
141
+ * @property {string} name The session's name, its own among the extension's sessions ('topics').
142
+ * @property {LanguageModelOptions} options What sessions are made (and availability asked) with.
143
+ * @property {InitialPrompt[]} [initialPrompts] The instructions and examples every question starts from.
144
+ * @property {HostSender} runtime `chrome.runtime`.
145
+ * @property {() => Promise<unknown>} open Makes sure the offscreen document is open
146
+ * (`() => watchFromOffscreen(chrome)`); only called when the worker has no Prompt API.
147
+ * @property {() => LanguageModelStatic | undefined} [model] The worker's own Prompt API, read when used.
148
+ * Default Chrome's.
149
+ */
150
+ /**
151
+ * A model session for a service worker: the worker's own Prompt API when it
152
+ * has one, else the offscreen document's. The same `ModelSession` either way.
153
+ * An answer's JSON Schema crosses to the offscreen document as JSON, so it
154
+ * must be a plain object there (not a RegExp).
155
+ *
156
+ * @param {HostedSessionSetup} setup - The session, and how to reach the offscreen document.
157
+ * @returns {ModelSession} The session.
158
+ * @example
159
+ * // background.js
160
+ * const words = createHostedModelSession({
161
+ * name: 'words', options: ENGLISH, initialPrompts: [{ role: 'system', content: 'You list words.' }],
162
+ * runtime: chrome.runtime, open: () => watchFromOffscreen(chrome),
163
+ * });
164
+ * const { text } = await words.ask('Topic: football', { timeoutMs: 20_000 });
165
+ */
166
+ export declare function createHostedModelSession({ name, options, initialPrompts, runtime, open, model }: HostedSessionSetup): ModelSession;
167
+ /**
168
+ * Answer the worker's model requests, in the offscreen document: one base
169
+ * session per name, made with the options and instructions the request
170
+ * carries (and again if they change). Requests from another extension, or
171
+ * from a content script (a web page's side), are ignored.
172
+ *
173
+ * @param {HostRuntime} runtime - `chrome.runtime`.
174
+ * @param {{model?: () => LanguageModelStatic | undefined}} [options] - The document's Prompt API. Default Chrome's.
175
+ * @returns {void}
176
+ * @example
177
+ * // offscreen/offscreen.js
178
+ * watchSchemeOffscreen(chrome.runtime);
179
+ * serveModelOffscreen(chrome.runtime);
180
+ */
181
+ export declare function serveModelOffscreen(runtime: HostRuntime, { model }?: {
182
+ model?: () => LanguageModelStatic | undefined;
183
+ }): void;