@arraypress/extension-kit 1.1.0 → 1.2.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
@@ -10,6 +10,8 @@ The pieces ArrayPress's Chrome extensions share, so each is written once:
10
10
  service worker shows the matching icon.
11
11
  - **About links**: README, Issues and PRIVACY.md in the extension's GitHub repository, plus
12
12
  package-time warnings for placeholder links.
13
+ - **Chrome's page components**: the ⋮ action menu and its row button, the one-field dialog
14
+ ("Rename folder"), and the toast with Undo, built on the stylesheet's classes.
13
15
  - **Build and test helpers** (Node): toolbar icon rendering, zipping for the Chrome Web Store, a
14
16
  fake `chrome.storage`, Playwright helpers, packaging checks, and a copied-code guard.
15
17
 
@@ -132,6 +134,68 @@ At the end of `npm run package`, `printReleaseChecks(ABOUT_LINKS, REPOSITORY)` w
132
134
  link is a placeholder (`isPlaceholder`: example.com/.org/.net) and reminds you the repository must
133
135
  be public before the store listing goes live.
134
136
 
137
+ ## Page components
138
+
139
+ Chrome's own components, made in the page by script (no markup to copy), styled by `chrome-ui.css`.
140
+ Each takes `doc` (default `document`); the wording defaults to Chrome's English and takes a
141
+ translation.
142
+
143
+ ### ⋮ menu (cr-action-menu)
144
+
145
+ ```js
146
+ import { createMenu, moreActionsButton } from '../vendor/extension-kit/index.js';
147
+
148
+ const menu = createMenu(); // one <dialog class="menu" role="menu"> for the page
149
+ row.append(moreActionsButton(workspace.name, {
150
+ onClick: (anchor) => menu.open([
151
+ { label: 'Rename', run: () => rename(workspace) },
152
+ 'separator',
153
+ { label: 'Delete', run: () => remove(workspace), disabled: locked },
154
+ ], { anchor }),
155
+ }));
156
+ // A right-click: menu.open(items, { x: event.clientX, y: event.clientY, focusTo: row });
157
+ ```
158
+
159
+ It opens modally over its button, as Chrome's does (its top at the button's top, its end at the
160
+ button's end), moved to stay 8px inside the window. A click outside, Esc, Tab or the window losing
161
+ focus closes it; ↑/↓ (wrapping, past disabled items), Home and End move; focus goes back to the
162
+ button (or `focusTo`); an item runs once the menu has closed. `createMenu({id, placement: 'below'})`
163
+ opens it below the button instead. `menuPosition` and `isInside` are the pure geometry.
164
+
165
+ `moreActionsButton(name, options)` is the 32px ⋮ icon button, labelled "More actions for “name”"
166
+ (`label` for a translation), with `aria-haspopup="menu"`; its icon is the stylesheet's
167
+ `.icon-more-vert` (`--icon-more-vert`, cr:more-vert), so the page needs no sprite. `className`,
168
+ `tooltip` (a `title`) and `focusable: false` (out of the tab order, for a row that takes the focus)
169
+ cover the rest.
170
+
171
+ ### One-field dialog (cr-dialog)
172
+
173
+ ```js
174
+ const name = await askText({ title: 'Rename workspace', label: 'Name', value: workspace.name, maxLength: 200 });
175
+ const site = await askText({
176
+ title: 'Add site', label: 'Site', action: 'Add',
177
+ validate: (text) => (siteFromInput(text) ? null : 'Enter a site, like example.com'),
178
+ });
179
+ ```
180
+
181
+ Resolves to the text, trimmed, or `null` when cancelled. The action button (default "Save") stays
182
+ disabled while the text is blank or `validate` gives a reason, which shows under the field
183
+ (`checkText` is that rule, pure). Enter presses the action button; Esc and Cancel cancel; the
184
+ focus goes back to what had it, or to `returnTo` (an element, or a function asked at the end).
185
+ One `#text-dialog` per page, made on first use.
186
+
187
+ ### Toast with Undo (cr-toast)
188
+
189
+ ```js
190
+ const toast = createToast(); // at load, so screen readers know the live region
191
+ toast.show(`Deleted “${workspace.name}”`, () => restore(workspace));
192
+ toast.show('Copied'); // no Undo
193
+ ```
194
+
195
+ `#toast` (`role="status"`), bottom left. A new message replaces the last and restarts the wait:
196
+ 10 seconds, as chrome://bookmarks's (`TOAST_MS`), or `duration` / `undoDuration`. Undo hides it
197
+ and runs once. `undoLabel` translates the button.
198
+
135
199
  ## Build helpers (`@arraypress/extension-kit/build`)
136
200
 
137
201
  | Export | What it does |
@@ -164,7 +228,10 @@ except those listed, each with a reason:
164
228
  | `until(fn, what, timeout?)` | Poll until truthy. |
165
229
  | `offscreenEmulator(cdp)` | Emulate light/dark inside the offscreen document. |
166
230
  | `recordIconCalls(worker)` | Record `chrome.action.setIcon` calls Chrome accepted. |
167
- | `filesUnder`, `zipEntries`, `missingReferences`, `manifestReferences` | For a packaging test: the zip is the folder, and every path the extension names resolves. |
231
+ | `installWarnings(worker, manifest)` | Chrome's own install warnings for a manifest (`chrome.management.getPermissionWarningsByManifest`, no permission needed), from a loaded extension's worker. |
232
+ | `warningsAddedBy(worker, manifest, names)` | `{warnings, without, added}`: the warnings with and without some permissions, and those only they bring (`withoutPermissions` is the pure part). |
233
+ | `savedTabGroups(profile)` | Chrome's saved tab groups (`{title, pinned, urls}`) read from a closed profile's `Sync Data/LevelDB`: no extension API lists them. |
234
+ | `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`). |
168
235
 
169
236
  ## Stylesheet classes
170
237
 
@@ -189,9 +256,10 @@ except those listed, each with a reason:
189
256
  | Buttons (cr-button) | plain `<button>`; `.action` filled; `.link` as a link; `.icon-button` 32px round |
190
257
  | Settings menu | `.nav` > `ul.nav-list` > `a.nav-item[aria-current="page"]`, `.nav-separator` |
191
258
  | Manager layout | `.manager`/`.layout`, `.sidebar` with `.tree` > `.tree-item`, `.main` with `.list-card` |
192
- | Menu (cr-action-menu) | `<dialog class="menu">` of `button[role="menuitem"]`, `hr` |
193
- | Dialog (cr-dialog) | `<dialog class="dialog"><form method="dialog">` `.dialog-title`, `.dialog-body`, `.dialog-buttons` |
194
- | Toast (cr-toast) | `<div class="toast">text <button>Undo</button></div>` |
259
+ | Menu (cr-action-menu) | `<dialog class="menu">` of `button[role="menuitem"]`, `hr` (or `createMenu`) |
260
+ | ⋮ icon | `<span class="icon icon-more-vert" aria-hidden="true">` (or `moreActionsButton`) |
261
+ | Dialog (cr-dialog) | `<dialog class="dialog"><form method="dialog">` `.dialog-title`, `.dialog-body`, `.dialog-buttons` (one field: `askText`) |
262
+ | Toast (cr-toast) | `<div class="toast">text <button>Undo</button></div>` (or `createToast`) |
195
263
  | Drawer (cr-drawer) | `<dialog class="drawer">` with `.drawer-head` and a `.nav` |
196
264
  | Side panel page | `<body class="side-panel">`: the panel's colours, a column 100vh high |
197
265
  | Panel tabs (cr-tabs) | `<div class="tabs" role="tablist">` of `button.tab[role="tab"][aria-selected]` |
@@ -209,7 +277,8 @@ chrome://bookmarks in Chromium 156 / Chrome 157; the sources are listed at the t
209
277
 
210
278
  ## Development
211
279
 
212
- `npm test` runs ESLint, a strict TypeScript check of the JSDoc (`checkJs`), the tests, and the
280
+ `npm test` runs ESLint, a strict TypeScript check of the JSDoc (`checkJs`), the tests (the menu,
281
+ the dialog and the Chrome readers in Playwright's Chromium, the rest in Node and jsdom), and the
213
282
  copied-code guard. The `.d.ts` files are generated from the JSDoc: `npm run types` writes them, and
214
283
  the type check fails while one is stale (it regenerates them into `.types/` and compares).
215
284
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arraypress/extension-kit",
3
- "version": "1.1.0",
3
+ "version": "1.2.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",
@@ -19,7 +19,7 @@ import path from 'node:path';
19
19
  const SOURCE = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
20
20
 
21
21
  /** What a vendored copy holds, relative to `src/`: everything an extension page or worker may load. */
22
- export const RUNTIME_ENTRIES = Object.freeze(['index.js', 'index.d.ts', 'about', 'commands', 'dom', 'settings', 'styles', 'toolbar']);
22
+ export const RUNTIME_ENTRIES = Object.freeze(['index.js', 'index.d.ts', 'about', 'commands', 'dom', 'settings', 'styles', 'toolbar', 'ui']);
23
23
 
24
24
  /** The kit's stylesheet, an absolute path in the installed package. */
25
25
  export const STYLESHEET = path.join(SOURCE, 'styles', 'chrome-ui.css');
package/src/index.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * The kit's browser half: what an extension's pages, offscreen document and
3
- * service worker load. Every function takes the `chrome` APIs it uses as
4
- * arguments, so tests run the real logic against fakes.
3
+ * service worker load, Chrome's page components (the ⋮ menu, the one-field
4
+ * dialog, the toast with Undo) included. Every function takes the `chrome`
5
+ * APIs it uses as arguments, so tests run the real logic against fakes.
5
6
  *
6
7
  * Build-time helpers are in `@arraypress/extension-kit/build`, test helpers
7
8
  * in `@arraypress/extension-kit/testing`, and the stylesheet is
@@ -19,3 +20,22 @@ export { createSettingsStore } from './settings/store.js';
19
20
  export { POLL_MS, watchSchemeOffscreen } from './toolbar/offscreen.js';
20
21
  export { DARK_QUERY, TOOLBAR_DARK_KEY, TOOLBAR_SCHEME, reportColorScheme, schemeSender, watchColorScheme } from './toolbar/scheme.js';
21
22
  export { OFFSCREEN_PATH, TOOLBAR_ICON_DIR, createToolbarTheme, startToolbarTheme, toolbarIcon, watchFromOffscreen, } from './toolbar/theme.js';
23
+ export { MENU_EDGE, createMenu, isInside, menuPosition, moreActionsButton } from './ui/menu.js';
24
+ export { askText, checkText } from './ui/text-dialog.js';
25
+ export { TOAST_MS, createToast } from './ui/toast.js';
26
+ export type ActionMenu = import('./ui/menu.js').ActionMenu;
27
+ export type MenuEntry = import('./ui/menu.js').MenuEntry;
28
+ export type MenuItem = import('./ui/menu.js').MenuItem;
29
+ export type MenuPlace = import('./ui/menu.js').MenuPlace;
30
+ export type TextRequest = import('./ui/text-dialog.js').TextRequest;
31
+ export type Toast = import('./ui/toast.js').Toast;
32
+ /**
33
+ * The page helpers' types, for a page's JSDoc.
34
+ *
35
+ * @typedef {import('./ui/menu.js').ActionMenu} ActionMenu
36
+ * @typedef {import('./ui/menu.js').MenuEntry} MenuEntry
37
+ * @typedef {import('./ui/menu.js').MenuItem} MenuItem
38
+ * @typedef {import('./ui/menu.js').MenuPlace} MenuPlace
39
+ * @typedef {import('./ui/text-dialog.js').TextRequest} TextRequest
40
+ * @typedef {import('./ui/toast.js').Toast} Toast
41
+ */
package/src/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * The kit's browser half: what an extension's pages, offscreen document and
3
- * service worker load. Every function takes the `chrome` APIs it uses as
4
- * arguments, so tests run the real logic against fakes.
3
+ * service worker load, Chrome's page components (the ⋮ menu, the one-field
4
+ * dialog, the toast with Undo) included. Every function takes the `chrome`
5
+ * APIs it uses as arguments, so tests run the real logic against fakes.
5
6
  *
6
7
  * Build-time helpers are in `@arraypress/extension-kit/build`, test helpers
7
8
  * in `@arraypress/extension-kit/testing`, and the stylesheet is
@@ -22,3 +23,17 @@ export { DARK_QUERY, TOOLBAR_DARK_KEY, TOOLBAR_SCHEME, reportColorScheme, scheme
22
23
  export {
23
24
  OFFSCREEN_PATH, TOOLBAR_ICON_DIR, createToolbarTheme, startToolbarTheme, toolbarIcon, watchFromOffscreen,
24
25
  } from './toolbar/theme.js';
26
+ export { MENU_EDGE, createMenu, isInside, menuPosition, moreActionsButton } from './ui/menu.js';
27
+ export { askText, checkText } from './ui/text-dialog.js';
28
+ export { TOAST_MS, createToast } from './ui/toast.js';
29
+
30
+ /**
31
+ * The page helpers' types, for a page's JSDoc.
32
+ *
33
+ * @typedef {import('./ui/menu.js').ActionMenu} ActionMenu
34
+ * @typedef {import('./ui/menu.js').MenuEntry} MenuEntry
35
+ * @typedef {import('./ui/menu.js').MenuItem} MenuItem
36
+ * @typedef {import('./ui/menu.js').MenuPlace} MenuPlace
37
+ * @typedef {import('./ui/text-dialog.js').TextRequest} TextRequest
38
+ * @typedef {import('./ui/toast.js').Toast} Toast
39
+ */
@@ -136,9 +136,10 @@
136
136
  --tonal-text: rgb(4 30 73);
137
137
  --elevation-3: 0 1px 3px 0 rgb(60 64 67 / 0.3), 0 4px 8px 3px rgb(60 64 67 / 0.15);
138
138
 
139
- /* Icons for link rows (cr:open-in-new, cr:arrow-right / subpage arrow). */
139
+ /* Icons for link rows (cr:open-in-new, cr:arrow-right / subpage arrow) and ⋮ buttons (cr:more-vert). */
140
140
  --icon-open-in-new: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M19 19H5V5h7V3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14c1.1 0 2-.9 2-2v-7h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z'/%3E%3C/svg%3E");
141
141
  --icon-arrow-right: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M10 6 8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z'/%3E%3C/svg%3E");
142
+ --icon-more-vert: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z'/%3E%3C/svg%3E");
142
143
 
143
144
  /* Sizes. */
144
145
  --radius: 8px; /* cards, fields, dialogs */
@@ -296,6 +297,9 @@ button.link:hover { background: none; text-decoration: underline; }
296
297
 
297
298
  .icon { width: 20px; height: 20px; fill: currentColor; flex: none; }
298
299
 
300
+ /* The ⋮ icon without a sprite: <span class="icon icon-more-vert" aria-hidden="true">. */
301
+ .icon-more-vert { display: inline-block; background: currentColor; mask: var(--icon-more-vert) center / contain no-repeat; }
302
+
299
303
  .sprite { position: absolute; width: 0; height: 0; overflow: hidden; }
300
304
 
301
305
  /* --------------------------------------------------------------- Fields */
@@ -1,18 +1,22 @@
1
1
  /**
2
2
  * The kit's test helpers: a fake `chrome.storage`, Playwright helpers for
3
- * end-to-end tests and a server for their pages, and checks for a packaging
4
- * test. Node only.
3
+ * end-to-end tests and a server for their pages, Chrome's install warnings
4
+ * and saved tab groups, and checks for a packaging test. Node only.
5
5
  *
6
6
  * @module testing
7
7
  */
8
8
  export { checkRunner, checkToolbarTheme, extensionProfile, launchExtension, offscreenEmulator, recordIconCalls, until } from './e2e.js';
9
9
  export { serve } from './server.js';
10
10
  export { SYNC_WRITES_PER_MINUTE, fakeChromeStorage } from './fake-storage.js';
11
- export { filesUnder, manifestReferences, missingReferences, zipEntries } from './package.js';
11
+ export { filesUnder, manifestReferences, missingReferences, referencedPath, zipEntries } from './package.js';
12
+ export { savedTabGroups } from './saved-groups.js';
13
+ export { installWarnings, warningsAddedBy, withoutPermissions } from './warnings.js';
12
14
  export type CheckRunner = import('./e2e.js').CheckRunner;
13
15
  export type ExtensionProfile = import('./e2e.js').ExtensionProfile;
14
16
  export type LaunchedExtension = import('./e2e.js').LaunchedExtension;
15
17
  export type LaunchOptions = import('./e2e.js').LaunchOptions;
18
+ export type SavedTabGroup = import('./saved-groups.js').SavedTabGroup;
19
+ export type Manifest = import('./warnings.js').Manifest;
16
20
  export type Reply = import('./server.js').Reply;
17
21
  export type Routes = import('./server.js').Routes;
18
22
  export type TestServer = import('./server.js').TestServer;
@@ -23,6 +27,8 @@ export type TestServer = import('./server.js').TestServer;
23
27
  * @typedef {import('./e2e.js').ExtensionProfile} ExtensionProfile
24
28
  * @typedef {import('./e2e.js').LaunchedExtension} LaunchedExtension
25
29
  * @typedef {import('./e2e.js').LaunchOptions} LaunchOptions
30
+ * @typedef {import('./saved-groups.js').SavedTabGroup} SavedTabGroup
31
+ * @typedef {import('./warnings.js').Manifest} Manifest
26
32
  * @typedef {import('./server.js').Reply} Reply
27
33
  * @typedef {import('./server.js').Routes} Routes
28
34
  * @typedef {import('./server.js').TestServer} TestServer
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * The kit's test helpers: a fake `chrome.storage`, Playwright helpers for
3
- * end-to-end tests and a server for their pages, and checks for a packaging
4
- * test. Node only.
3
+ * end-to-end tests and a server for their pages, Chrome's install warnings
4
+ * and saved tab groups, and checks for a packaging test. Node only.
5
5
  *
6
6
  * @module testing
7
7
  */
@@ -9,7 +9,9 @@
9
9
  export { checkRunner, checkToolbarTheme, extensionProfile, launchExtension, offscreenEmulator, recordIconCalls, until } from './e2e.js';
10
10
  export { serve } from './server.js';
11
11
  export { SYNC_WRITES_PER_MINUTE, fakeChromeStorage } from './fake-storage.js';
12
- export { filesUnder, manifestReferences, missingReferences, zipEntries } from './package.js';
12
+ export { filesUnder, manifestReferences, missingReferences, referencedPath, zipEntries } from './package.js';
13
+ export { savedTabGroups } from './saved-groups.js';
14
+ export { installWarnings, warningsAddedBy, withoutPermissions } from './warnings.js';
13
15
 
14
16
  /**
15
17
  * The helpers' types, for a test's JSDoc.
@@ -18,6 +20,8 @@ export { filesUnder, manifestReferences, missingReferences, zipEntries } from '.
18
20
  * @typedef {import('./e2e.js').ExtensionProfile} ExtensionProfile
19
21
  * @typedef {import('./e2e.js').LaunchedExtension} LaunchedExtension
20
22
  * @typedef {import('./e2e.js').LaunchOptions} LaunchOptions
23
+ * @typedef {import('./saved-groups.js').SavedTabGroup} SavedTabGroup
24
+ * @typedef {import('./warnings.js').Manifest} Manifest
21
25
  * @typedef {import('./server.js').Reply} Reply
22
26
  * @typedef {import('./server.js').Routes} Routes
23
27
  * @typedef {import('./server.js').TestServer} TestServer
@@ -20,17 +20,75 @@ export declare function filesUnder(dir: string): Promise<string[]>;
20
20
  * @returns {Promise<string[]>} Entry names, sorted.
21
21
  */
22
22
  export declare function zipEntries(from: string, exclude?: readonly string[]): Promise<string[]>;
23
+ export type ManifestFiles = {
24
+ /**
25
+ * The worker.
26
+ */
27
+ background?: {
28
+ service_worker?: string;
29
+ };
30
+ /**
31
+ * The options page.
32
+ */
33
+ options_ui?: {
34
+ page?: string;
35
+ };
36
+ /**
37
+ * The popup and toolbar icon.
38
+ */
39
+ action?: {
40
+ default_popup?: string;
41
+ default_icon?: string | Record<string, string>;
42
+ };
43
+ /**
44
+ * The side panel.
45
+ */
46
+ side_panel?: {
47
+ default_path?: string;
48
+ };
49
+ /**
50
+ * The extension's icons.
51
+ */
52
+ icons?: Record<string, string>;
53
+ /**
54
+ * Files pages may load.
55
+ */
56
+ web_accessible_resources?: {
57
+ resources: string[];
58
+ }[];
59
+ };
60
+ /**
61
+ * The parts of a manifest that name files.
62
+ *
63
+ * @typedef {object} ManifestFiles
64
+ * @property {{service_worker?: string}} [background] The worker.
65
+ * @property {{page?: string}} [options_ui] The options page.
66
+ * @property {{default_popup?: string, default_icon?: string | Record<string, string>}} [action] The popup and toolbar icon.
67
+ * @property {{default_path?: string}} [side_panel] The side panel.
68
+ * @property {Record<string, string>} [icons] The extension's icons.
69
+ * @property {{resources: string[]}[]} [web_accessible_resources] Files pages may load.
70
+ */
23
71
  /**
24
72
  * The paths a manifest names, from the extension's root.
25
73
  *
26
- * @param {Record<string, any>} manifest - The parsed `manifest.json`.
27
- * @returns {string[]} Worker, pages, icons, side panel and web-accessible resources.
74
+ * @param {ManifestFiles} manifest - The parsed `manifest.json`.
75
+ * @returns {string[]} Worker, pages, icons, side panel and web-accessible resources (less patterns with `*`).
76
+ */
77
+ export declare function manifestReferences(manifest: ManifestFiles): string[];
78
+ /**
79
+ * The file a reference names: the path without its `#fragment` or `?query`
80
+ * (`options.html#workspaces` is `options.html`). Pure.
81
+ *
82
+ * @param {string} reference - A path as the extension writes it.
83
+ * @returns {string} The path alone; '' for a reference to the page itself (`#top`).
28
84
  */
29
- export declare function manifestReferences(manifest: Record<string, any>): string[];
85
+ export declare function referencedPath(reference: string): string;
30
86
  /**
31
87
  * Every path the extension names that isn't a file inside it: relative
32
88
  * imports in its scripts, `chrome.runtime.getURL('…')` paths, `src`/`href`
33
- * in its pages, the manifest's entries, and any `extra` paths.
89
+ * in its pages, the manifest's entries, and any `extra` paths. A `#fragment`
90
+ * or `?query` is ignored: `getURL('options.html#workspaces')` names
91
+ * `options.html`.
34
92
  *
35
93
  * @param {string} dir - The extension folder.
36
94
  * @param {readonly string[]} [extra] - More paths from the root (say, a script injected by name).
@@ -43,28 +43,57 @@ export async function zipEntries(from, exclude) {
43
43
  }
44
44
  }
45
45
 
46
+ /**
47
+ * The parts of a manifest that name files.
48
+ *
49
+ * @typedef {object} ManifestFiles
50
+ * @property {{service_worker?: string}} [background] The worker.
51
+ * @property {{page?: string}} [options_ui] The options page.
52
+ * @property {{default_popup?: string, default_icon?: string | Record<string, string>}} [action] The popup and toolbar icon.
53
+ * @property {{default_path?: string}} [side_panel] The side panel.
54
+ * @property {Record<string, string>} [icons] The extension's icons.
55
+ * @property {{resources: string[]}[]} [web_accessible_resources] Files pages may load.
56
+ */
57
+
46
58
  /**
47
59
  * The paths a manifest names, from the extension's root.
48
60
  *
49
- * @param {Record<string, any>} manifest - The parsed `manifest.json`.
50
- * @returns {string[]} Worker, pages, icons, side panel and web-accessible resources.
61
+ * @param {ManifestFiles} manifest - The parsed `manifest.json`.
62
+ * @returns {string[]} Worker, pages, icons, side panel and web-accessible resources (less patterns with `*`).
51
63
  */
52
64
  export function manifestReferences(manifest) {
65
+ const icon = manifest.action?.default_icon;
53
66
  return [
54
67
  manifest.background?.service_worker,
55
68
  manifest.options_ui?.page,
56
69
  manifest.action?.default_popup,
57
70
  manifest.side_panel?.default_path,
58
71
  ...Object.values(manifest.icons ?? {}),
59
- ...Object.values(manifest.action?.default_icon ?? {}),
60
- ...(manifest.web_accessible_resources ?? []).flatMap((/** @type {{resources: string[]}} */ entry) => entry.resources),
61
- ].filter((ref) => typeof ref === 'string' && !ref.includes('*'));
72
+ ...(typeof icon === 'string' ? [icon] : Object.values(icon ?? {})),
73
+ ...(manifest.web_accessible_resources ?? []).flatMap((entry) => entry.resources),
74
+ ].filter((ref) => typeof ref === 'string').filter((ref) => !ref.includes('*'));
75
+ }
76
+
77
+ /**
78
+ * The file a reference names: the path without its `#fragment` or `?query`
79
+ * (`options.html#workspaces` is `options.html`). Pure.
80
+ *
81
+ * @param {string} reference - A path as the extension writes it.
82
+ * @returns {string} The path alone; '' for a reference to the page itself (`#top`).
83
+ */
84
+ export function referencedPath(reference) {
85
+ return reference.replace(/[?#].*$/s, '');
62
86
  }
63
87
 
88
+ /** A reference with a scheme (`https:`, `data:`, `mailto:`) or protocol-relative: not a file of the extension. */
89
+ const ELSEWHERE = /^(?:[a-z][a-z\d+.-]*:|\/\/)/i;
90
+
64
91
  /**
65
92
  * Every path the extension names that isn't a file inside it: relative
66
93
  * imports in its scripts, `chrome.runtime.getURL('…')` paths, `src`/`href`
67
- * in its pages, the manifest's entries, and any `extra` paths.
94
+ * in its pages, the manifest's entries, and any `extra` paths. A `#fragment`
95
+ * or `?query` is ignored: `getURL('options.html#workspaces')` names
96
+ * `options.html`.
68
97
  *
69
98
  * @param {string} dir - The extension folder.
70
99
  * @param {readonly string[]} [extra] - More paths from the root (say, a script injected by name).
@@ -76,7 +105,9 @@ export async function missingReferences(dir, extra = []) {
76
105
  const missing = [];
77
106
  /** @param {string} from @param {string} target */
78
107
  const check = (from, target) => {
79
- const file = path.resolve(root, from, target);
108
+ const name = referencedPath(target);
109
+ if (!name || ELSEWHERE.test(target)) return;
110
+ const file = path.resolve(root, from, name);
80
111
  if (!file.startsWith(root) || !existsSync(file)) missing.push(`${from || 'root'} → ${target}`);
81
112
  };
82
113
  for (const file of await filesUnder(root)) {
@@ -87,7 +118,7 @@ export async function missingReferences(dir, extra = []) {
87
118
  for (const [, ref] of source.matchAll(/getURL\('([^']+)'\)/g)) check('', ref);
88
119
  } else if (file.endsWith('.html')) {
89
120
  const source = await readFile(path.join(root, file), 'utf8');
90
- for (const [, ref] of source.matchAll(/(?:src|href)="([^"#:]+)"/g)) check(from, ref);
121
+ for (const [, ref] of source.matchAll(/(?:src|href)="([^"]+)"/g)) check(from, ref);
91
122
  }
92
123
  }
93
124
  const manifest = JSON.parse(await readFile(path.join(root, 'manifest.json'), 'utf8'));
@@ -0,0 +1,33 @@
1
+ export type SavedTabGroup = {
2
+ /**
3
+ * Its title.
4
+ */
5
+ title: string;
6
+ /**
7
+ * Whether it's pinned to the saved groups bar.
8
+ */
9
+ pinned: boolean;
10
+ /**
11
+ * Its tabs' addresses, in order.
12
+ */
13
+ urls: string[];
14
+ };
15
+ export type ProtoField = {
16
+ field: number;
17
+ value: number | Buffer;
18
+ };
19
+ /**
20
+ * Chrome's saved tab groups in a closed profile.
21
+ *
22
+ * @param {string} profile - The profile folder (as `extensionProfile` gives it).
23
+ * @returns {Promise<SavedTabGroup[]>} The saved groups, in no particular order; none when the
24
+ * profile has no store yet.
25
+ * @throws {Error} When the store has compacted tables this doesn't read.
26
+ * @example
27
+ * const profile = await extensionProfile(chromium, 'extension');
28
+ * const { worker, close } = await profile.launch();
29
+ * await worker.evaluate(async () => chrome.tabGroups.update(await chrome.tabs.group({tabIds: [tabId]}), {title: 'Research'}));
30
+ * await close();
31
+ * const groups = await savedTabGroups(profile.dir); // → [{title: 'Research', pinned: true, urls: [...]}]
32
+ */
33
+ export declare function savedTabGroups(profile: string): Promise<SavedTabGroup[]>;