@arraypress/extension-kit 1.9.0 → 1.10.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
@@ -245,16 +245,54 @@ for (const { frameId, result } of frames) show(frameId, result);
245
245
  // From the toolbar click: the tab's own side panel, panel/panel.html?tab=<id>, nothing awaited first.
246
246
  chrome.action.onClicked.addListener((tab) => openSidePanel(chrome.sidePanel, tab.id, 'panel/panel.html'));
247
247
 
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; }' });
248
+ // In the panel: every frame of the tab, or why it can't be read.
249
+ const reading = await callInTab(chrome, tabId, 'content/main.js', 'scan');
250
+ if (reading.state === 'read') show(reading.frames); // top frame first
251
+ else showMessage(reading.state); // 'browser' (Chrome's own page) or 'no-access'
252
+
253
+ // In the content script: a layer the page's styles can't touch (a closed shadow root). The host's
254
+ // own styles go in hostStyle: its inline `all: initial !important` reset beats a `:host` rule.
255
+ const { root, remove } = pageOverlay(document, {
256
+ name: 'my-inspector',
257
+ hostStyle: { position: 'fixed', inset: '0', 'z-index': '2147483647', 'pointer-events': 'none' },
258
+ css: `${HIGHLIGHT_CSS} .card { position: absolute; }`,
259
+ });
260
+
261
+ // “Show on page”: boxes in DevTools' blue, kept on the elements as the page scrolls; Esc clears.
262
+ const highlight = createHighlighter(document, { root: () => root });
263
+ highlight.show(elements, { reveal: true });
250
264
  ```
251
265
 
252
266
  `callInPage(scripting, target, module, name, args, {immediately})` resolves to each frame's
253
267
  `{frameId, result}` (frames Chrome couldn't run in left out) and rejects when it can run in none;
254
268
  `immediately` (default true) doesn't wait for the page to be idle. `openSidePanel` sets the tab's
255
269
  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.
270
+ only while the click counts. `callInTab(edges, tabId, module, name, args)` calls it in every frame
271
+ and gives `{state: 'read', frames}` (frames that answered, top first), or `{state: 'browser'}` for
272
+ a page Chrome keeps to itself (`isBrowserPage(url)`: its own pages, the Web Store) or
273
+ `{state: 'no-access'}` (the tab went to another site since the click). `pageOverlay` puts a host
274
+ element of its own name on the root element, replacing one a run before left; `root` is its closed
275
+ shadow root, `css` first. The host is reset inline (`all: initial !important`) so the page can't
276
+ move or hide it; that also beats a `:host { … }` rule and `host.style.x = …`, so the host's own
277
+ styles go in `hostStyle` (set `!important` after the reset). `createHighlighter(doc, {root, onClear,
278
+ max})` boxes up to 500 elements (`MAX_BOXES`) in the overlay (`HIGHLIGHT_CSS` styles them), only
279
+ those in the window, following the page as it scrolls; `show` replaces the set, `clear` or Esc
280
+ ends it.
281
+
282
+ ```js
283
+ // A page's elements in a line, and copying from a click (the copy command where the API can't).
284
+ row.append(make('span', 'meta', '12 colours'), iconButton({ icon: 'i-copy', label: t('copy'), onClick: () => copyFormats(document, { text: hex }) }));
285
+ ```
286
+
287
+ `make(tag, className, text, doc)`, `spriteIcon(id, className, doc)` (a `<use>` of the page's
288
+ sprite) and `iconButton({icon, label, onClick, className, doc})` (tooltip and label) take the
289
+ document the element belongs to, so a content script's overlay uses them too.
290
+ `copyFormats(doc, {text, html})` puts plain text and HTML on the clipboard at once (a titled link
291
+ for rich editors, its plain text for the rest) and resolves to whether it did; a content script
292
+ can call it from a menu click or a shortcut (Chrome lets the focused active tab write), and
293
+ `showPageToast(doc, 'Link copied', {name})` confirms it with Chrome's own toast in the page.
294
+ `noticeCard({icon, tone, title, text, buttons, actions})` is a side panel's notice (`.sp-notice`:
295
+ frames from other sites to allow); `counted(count, one, many, {t, lang})` a count in words.
258
296
 
259
297
  ## A content script on every site
260
298
 
@@ -350,6 +388,13 @@ changing (at most `maxWaitMs` while it keeps on); an element with a few children
350
388
  default 6) whose children changed is handed over whole. Both are pure DOM, so a content script
351
389
  imports them directly (list them in `web_accessible_resources`).
352
390
 
391
+ `renderedElements(doc, {skip, limit})` (`content/elements.js`) lists every element Chrome draws,
392
+ in document order, into open and closed shadow roots (`shadowRootOf`, through
393
+ `chrome.dom.openOrClosedShadowRoot`) and through `display: contents` wrappers; what's inside a
394
+ `visibility: hidden` element is still read, nothing inside `display: none`; at most 20,000
395
+ (`MAX_ELEMENTS`). `otherSiteFrames(doc)` gives the origins of frames from other sites, which a
396
+ reading of the tab leaves out until the person allows those sites.
397
+
353
398
  ## Time limits
354
399
 
355
400
  ```js
@@ -513,9 +558,10 @@ A list with an Add button needs every key but `optionsRemoveLabel`; one with an
513
558
  | `vendorPackage(name, files, dest)`, `packageDir(name)` | Copy files of an installed package, stamped (`extension-kit vendor <dir> <package> <path>...`). |
514
559
  | `checkVendored(dir)` | The stale copies under `dir` (`extension-kit vendor-check`). |
515
560
  | `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. |
561
+ | `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
562
  | `testBuildDir(root, name)` | `.test-builds/<name>`, or under `$EXTENSION_TEST_BUILDS` (to test another build, or run two side by side). |
518
563
  | `storeFrame` | Store screenshots: `canvas`, `windowChrome` (and `windowChromeSvg`, from `frame/window.js`), `geometry`, `letterIcon`, `cursor`, `THEMES`, `W`×`H`. |
564
+ | `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
565
  | `placeholderWarning`, `repositoryReminder`, `printReleaseChecks` | See About links. |
520
566
  | `findDisallowedClones({runJscpd})` | Run jscpd and return clones `.jscpd-allowlist.json` doesn't cover (the `extension-kit duplication` command). |
521
567
 
@@ -546,6 +592,9 @@ except those listed, each with a reason:
546
592
  | `realisticBrowser(options)`, `chromeUserAgent(browser)` | Launch options and a user agent for a browser that doesn't say it's automated, for live checks. |
547
593
  | `closeOnSignal(close)` | On Ctrl-C or a kill, close the run's browsers, then exit. |
548
594
  | `testBuildDir(root, name)` | As in `/build`, for the tests that load the build. |
595
+ | `tabIdOf(worker, page)` | A Playwright page's tab id, as the extension's worker sees it. |
596
+ | `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}]`. |
597
+ | `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
598
  | `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
599
  | `until(fn, what, timeout?)` | Poll until truthy. |
551
600
  | `offscreenEmulator(cdp)` | Emulate light/dark inside the offscreen document. |
@@ -554,6 +603,7 @@ except those listed, each with a reason:
554
603
  | `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
604
  | `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
605
  | `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. |
606
+ | `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. |
557
607
  | `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
608
  | `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`). |
559
609
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arraypress/extension-kit",
3
- "version": "1.9.0",
3
+ "version": "1.10.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",
@@ -7,7 +7,8 @@
7
7
  export { clonesFromReport, disallowedClones, findDisallowedClones } from './duplication.js';
8
8
  export { placeholderWarning, printReleaseChecks, repositoryReminder } from './release-checks.js';
9
9
  export * as storeFrame from './store-frame.js';
10
- export { HOOK_FILE, TEST_BUILDS_ENV, buildTestCopy, testBuildDir } from './test-build.js';
10
+ export { PANEL_HEADER_H, PANEL_W, contextMenuHtml, pngUrl, storeShotHtml, storeShots } from './store-shots.js';
11
+ export { HOOK_FILE, PANEL_OPENER, TEST_BUILDS_ENV, buildTestCopy, testBuildDir } from './test-build.js';
11
12
  export { ICON_SIZES, TOOLBAR_COLORS, TOOLBAR_ICON_COLORS, TOOLBAR_ICON_SIZES, renderIcons, renderToolbarIcons, toolbarIconFile, } from './toolbar-icons.js';
12
13
  export { RUNTIME_ENTRIES, STYLESHEET, VENDOR_STAMP, checkVendored, packageDir, vendorKit, vendorPackage } from './vendor.js';
13
14
  export { ZIP_EXCLUDE, shipsFile, zipExtension } from './zip.js';
@@ -8,7 +8,8 @@
8
8
  export { clonesFromReport, disallowedClones, findDisallowedClones } from './duplication.js';
9
9
  export { placeholderWarning, printReleaseChecks, repositoryReminder } from './release-checks.js';
10
10
  export * as storeFrame from './store-frame.js';
11
- export { HOOK_FILE, TEST_BUILDS_ENV, buildTestCopy, testBuildDir } from './test-build.js';
11
+ export { PANEL_HEADER_H, PANEL_W, contextMenuHtml, pngUrl, storeShotHtml, storeShots } from './store-shots.js';
12
+ export { HOOK_FILE, PANEL_OPENER, TEST_BUILDS_ENV, buildTestCopy, testBuildDir } from './test-build.js';
12
13
  export {
13
14
  ICON_SIZES, TOOLBAR_COLORS, TOOLBAR_ICON_COLORS, TOOLBAR_ICON_SIZES, renderIcons, renderToolbarIcons, toolbarIconFile,
14
15
  } from './toolbar-icons.js';
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Store screenshots for the whole family, in one style: a caption band in sentence case over
3
+ * Chrome's window (light theme) with the extension pinned in the toolbar, real captures inside.
4
+ * What headless Chrome can't capture is composed around the real extension UI, as Chrome draws
5
+ * it: the toolbar popup hanging from the extension's button (the popup page captured on its own),
6
+ * the side panel beside the page (the panel page captured on its own) and the right-click menu
7
+ * (drawn, with the extension's real menu titles).
8
+ *
9
+ * {@link storeShotHtml} lays one out (pure); {@link storeShots} renders them in a Playwright
10
+ * page at 2× and writes 1280×800 PNGs. Captures are taken at 2× too
11
+ * (`deviceScaleFactor: 2`) and shown at their CSS size, so text stays sharp.
12
+ *
13
+ * @module build/store-shots
14
+ */
15
+ import type { Tab, Theme } from '../frame/window.js';
16
+ import type { Geometry } from './store-frame.js';
17
+ /** @import { Tab, Theme } from '../frame/window.js' */
18
+ /** @import { Geometry } from './store-frame.js' */
19
+ /** The side panel's width, as Chrome opens it (px). */
20
+ export declare const PANEL_W = 360;
21
+ /** The side panel's header: the extension's icon and name, and Chrome's close button (px). Capture a panel page `PANEL_HEADER_H` shorter than the content. */
22
+ export declare const PANEL_HEADER_H = 40;
23
+ export type MenuItem = {
24
+ /**
25
+ * Its words (none: a separator).
26
+ */
27
+ label?: string;
28
+ /**
29
+ * A 16px icon's URL (the extension's, beside its own item).
30
+ */
31
+ icon?: string;
32
+ /**
33
+ * It opens a submenu (an arrow).
34
+ */
35
+ submenu?: boolean;
36
+ /**
37
+ * The pointer is over it.
38
+ */
39
+ highlighted?: boolean;
40
+ /**
41
+ * Greyed out.
42
+ */
43
+ disabled?: boolean;
44
+ };
45
+ /**
46
+ * One row of the right-click menu.
47
+ *
48
+ * @typedef {object} MenuItem
49
+ * @property {string} [label] Its words (none: a separator).
50
+ * @property {string} [icon] A 16px icon's URL (the extension's, beside its own item).
51
+ * @property {boolean} [submenu] It opens a submenu (an arrow).
52
+ * @property {boolean} [highlighted] The pointer is over it.
53
+ * @property {boolean} [disabled] Greyed out.
54
+ */
55
+ /**
56
+ * The right-click menu as macOS draws Chrome's (a native menu): rounded, translucent, 13px rows,
57
+ * the row under the pointer in the accent colour; a submenu beside its highlighted row. Its class
58
+ * names start `ctx` (the window's own styles have `.sep`, `.tab`…).
59
+ *
60
+ * @param {object} menu - What to draw.
61
+ * @param {number} menu.x - Its left edge, in the window's content area (px).
62
+ * @param {number} menu.y - Its top edge, in the content area.
63
+ * @param {readonly MenuItem[]} menu.items - Its rows.
64
+ * @param {readonly MenuItem[]} [menu.submenu] - The highlighted row's submenu.
65
+ * @returns {string} The HTML, positioned in the content area.
66
+ */
67
+ export declare function contextMenuHtml({ x, y, items, submenu }: {
68
+ x: number;
69
+ y: number;
70
+ items: readonly MenuItem[];
71
+ submenu?: readonly MenuItem[];
72
+ }): string;
73
+ export type Shot = {
74
+ /**
75
+ * The caption, sentence case, a few words.
76
+ */
77
+ caption: string;
78
+ /**
79
+ * The window's tabs.
80
+ */
81
+ tabs: readonly Tab[];
82
+ /**
83
+ * The address bar's text.
84
+ */
85
+ url: string;
86
+ /**
87
+ * The start of `url` shown in full; the rest dimmed.
88
+ */
89
+ urlHost?: string;
90
+ /**
91
+ * The page's capture (a data: URL), at the content's size (less the side panel's when there's one).
92
+ */
93
+ page: string;
94
+ /**
95
+ * The popup's capture and its size: hung from the extension's toolbar button.
96
+ */
97
+ popup?: {
98
+ image: string;
99
+ width: number;
100
+ height: number;
101
+ };
102
+ /**
103
+ * The side panel's capture (`PANEL_W` wide, the content's height less `PANEL_HEADER_H`), the extension's name and icon for its header.
104
+ */
105
+ panel?: {
106
+ image: string;
107
+ title: string;
108
+ icon: string;
109
+ };
110
+ /**
111
+ * A right-click menu over the page.
112
+ */
113
+ menu?: Parameters<typeof contextMenuHtml>[0];
114
+ /**
115
+ * The mouse pointer, in the content area.
116
+ */
117
+ pointer?: {
118
+ x: number;
119
+ y: number;
120
+ };
121
+ };
122
+ /**
123
+ * One store screenshot.
124
+ *
125
+ * @typedef {object} Shot
126
+ * @property {string} caption The caption, sentence case, a few words.
127
+ * @property {readonly Tab[]} tabs The window's tabs.
128
+ * @property {string} url The address bar's text.
129
+ * @property {string} [urlHost] The start of `url` shown in full; the rest dimmed.
130
+ * @property {string} page The page's capture (a data: URL), at the content's size (less the side panel's when there's one).
131
+ * @property {{image: string, width: number, height: number}} [popup] The popup's capture and its size: hung from the extension's toolbar button.
132
+ * @property {{image: string, title: string, icon: string}} [panel] The side panel's capture (`PANEL_W` wide, the content's height less `PANEL_HEADER_H`), the extension's name and icon for its header.
133
+ * @property {Parameters<typeof contextMenuHtml>[0]} [menu] A right-click menu over the page.
134
+ * @property {{x: number, y: number}} [pointer] The mouse pointer, in the content area.
135
+ */
136
+ /**
137
+ * The HTML of one store screenshot: the caption, Chrome's window with the extension's toolbar
138
+ * button, and the captures; a popup hangs from the button (shown pressed), a side panel sits at
139
+ * the window's right, a menu over the page.
140
+ *
141
+ * @param {Shot & {toolbarIcon: string, theme?: Theme, geo?: Geometry}} shot - What to draw; the extension's toolbar icon (a data: URL).
142
+ * @returns {string} A whole HTML document, {@link W}×{@link H}.
143
+ */
144
+ export declare function storeShotHtml({ caption, tabs, url, urlHost, page, popup, panel, menu, pointer, toolbarIcon, theme, geo }: Shot & {
145
+ toolbarIcon: string;
146
+ theme?: Theme;
147
+ geo?: Geometry;
148
+ }): string;
149
+ /**
150
+ * A data: URL of a PNG.
151
+ *
152
+ * @param {Uint8Array} png - The PNG's bytes.
153
+ * @returns {string} The URL.
154
+ */
155
+ export declare const pngUrl: (png: Uint8Array) => string;
156
+ /**
157
+ * A store-screenshot writer: lays each shot out with {@link storeShotHtml} in a page of the
158
+ * given browser at 2×, waits for its images, and saves it as a {@link W}×{@link H} PNG.
159
+ *
160
+ * @param {object} options - Where and how.
161
+ * @param {import('playwright').Browser | import('playwright').BrowserContext} options.browser - A browser (or a context) to draw in.
162
+ * @param {string} options.dir - The folder to write to (made if missing).
163
+ * @param {string} options.toolbarIcon - The extension's toolbar icon, a data: URL.
164
+ * @returns {{geo: Geometry, shoot: (file: string, shot: Shot) => Promise<string>}} The window's geometry
165
+ * (capture pages at `geo.content`), and `shoot`, which writes one and returns its path.
166
+ * @example
167
+ * const { geo, shoot } = storeShots({ browser, dir: 'store/screenshots', toolbarIcon });
168
+ * await shoot('1-menu.png', { caption: 'Copy any page as Markdown', tabs, url, page: pngUrl(await page.screenshot()) });
169
+ */
170
+ export declare function storeShots({ browser, dir, toolbarIcon }: {
171
+ browser: import('playwright').Browser | import('playwright').BrowserContext;
172
+ dir: string;
173
+ toolbarIcon: string;
174
+ }): {
175
+ geo: Geometry;
176
+ shoot: (file: string, shot: Shot) => Promise<string>;
177
+ };
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Store screenshots for the whole family, in one style: a caption band in sentence case over
3
+ * Chrome's window (light theme) with the extension pinned in the toolbar, real captures inside.
4
+ * What headless Chrome can't capture is composed around the real extension UI, as Chrome draws
5
+ * it: the toolbar popup hanging from the extension's button (the popup page captured on its own),
6
+ * the side panel beside the page (the panel page captured on its own) and the right-click menu
7
+ * (drawn, with the extension's real menu titles).
8
+ *
9
+ * {@link storeShotHtml} lays one out (pure); {@link storeShots} renders them in a Playwright
10
+ * page at 2× and writes 1280×800 PNGs. Captures are taken at 2× too
11
+ * (`deviceScaleFactor: 2`) and shown at their CSS size, so text stays sharp.
12
+ *
13
+ * @module build/store-shots
14
+ */
15
+
16
+ import { mkdir, writeFile } from 'node:fs/promises';
17
+ import path from 'node:path';
18
+ import { esc } from '../frame/window.js';
19
+ import { H, W, WIN, canvas, cursor, geometry, windowChrome } from './store-frame.js';
20
+
21
+ /** @import { Tab, Theme } from '../frame/window.js' */
22
+ /** @import { Geometry } from './store-frame.js' */
23
+
24
+ /** The side panel's width, as Chrome opens it (px). */
25
+ export const PANEL_W = 360;
26
+
27
+ /** The side panel's header: the extension's icon and name, and Chrome's close button (px). Capture a panel page `PANEL_HEADER_H` shorter than the content. */
28
+ export const PANEL_HEADER_H = 40;
29
+
30
+ /**
31
+ * One row of the right-click menu.
32
+ *
33
+ * @typedef {object} MenuItem
34
+ * @property {string} [label] Its words (none: a separator).
35
+ * @property {string} [icon] A 16px icon's URL (the extension's, beside its own item).
36
+ * @property {boolean} [submenu] It opens a submenu (an arrow).
37
+ * @property {boolean} [highlighted] The pointer is over it.
38
+ * @property {boolean} [disabled] Greyed out.
39
+ */
40
+
41
+ /**
42
+ * The right-click menu as macOS draws Chrome's (a native menu): rounded, translucent, 13px rows,
43
+ * the row under the pointer in the accent colour; a submenu beside its highlighted row. Its class
44
+ * names start `ctx` (the window's own styles have `.sep`, `.tab`…).
45
+ *
46
+ * @param {object} menu - What to draw.
47
+ * @param {number} menu.x - Its left edge, in the window's content area (px).
48
+ * @param {number} menu.y - Its top edge, in the content area.
49
+ * @param {readonly MenuItem[]} menu.items - Its rows.
50
+ * @param {readonly MenuItem[]} [menu.submenu] - The highlighted row's submenu.
51
+ * @returns {string} The HTML, positioned in the content area.
52
+ */
53
+ export function contextMenuHtml({ x, y, items, submenu }) {
54
+ /** @param {readonly MenuItem[]} rows @returns {string} */
55
+ const list = (rows) => rows.map((row) => (row.label === undefined
56
+ ? '<div class="ctx-sep"></div>'
57
+ : `<div class="ctx-row${row.highlighted ? ' on' : ''}${row.disabled ? ' off' : ''}"><span class="ctx-ic">${row.icon ? `<img src="${row.icon}" width="16" height="16" alt="">` : ''}</span><span class="ctx-label">${esc(row.label)}</span>${row.submenu ? '<span class="ctx-arrow">›</span>' : ''}</div>`)).join('');
58
+ const at = items.findIndex((row) => row.highlighted && row.submenu);
59
+ const top = (/** @type {number} */ index) => 5 + items.slice(0, index).reduce((sum, row) => sum + (row.label === undefined ? 11 : 22), 0);
60
+ return `<div class="ctx" style="left:${x}px;top:${y}px">${list(items)}</div>`
61
+ + (submenu && at >= 0 ? `<div class="ctx" style="left:${x + 236}px;top:${y + top(at) - 5}px">${list(submenu)}</div>` : '')
62
+ + `<style>
63
+ .ctx { position: absolute; z-index: 10; width: 240px; box-sizing: border-box; padding: 5px; border-radius: 10px; background: rgba(246,246,246,.96);
64
+ box-shadow: 0 0 0 .5px rgba(0,0,0,.22), 0 10px 30px rgba(0,0,0,.22); font: 13px/22px system-ui; color: #1d1d1f; }
65
+ .ctx-row { display: flex; align-items: center; height: 22px; padding: 0 8px 0 4px; border-radius: 5px; white-space: nowrap; }
66
+ .ctx-row.on { background: #0a82ff; color: #fff; }
67
+ .ctx-row.off { color: #b0b0b0; }
68
+ .ctx-ic { width: 22px; flex: none; display: flex; }
69
+ .ctx-label { flex: 1; overflow: hidden; text-overflow: ellipsis; }
70
+ .ctx-arrow { flex: none; font-size: 15px; opacity: .7; }
71
+ .ctx-sep { height: 1px; margin: 5px 8px; background: rgba(0,0,0,.11); }
72
+ </style>`;
73
+ }
74
+
75
+ /**
76
+ * One store screenshot.
77
+ *
78
+ * @typedef {object} Shot
79
+ * @property {string} caption The caption, sentence case, a few words.
80
+ * @property {readonly Tab[]} tabs The window's tabs.
81
+ * @property {string} url The address bar's text.
82
+ * @property {string} [urlHost] The start of `url` shown in full; the rest dimmed.
83
+ * @property {string} page The page's capture (a data: URL), at the content's size (less the side panel's when there's one).
84
+ * @property {{image: string, width: number, height: number}} [popup] The popup's capture and its size: hung from the extension's toolbar button.
85
+ * @property {{image: string, title: string, icon: string}} [panel] The side panel's capture (`PANEL_W` wide, the content's height less `PANEL_HEADER_H`), the extension's name and icon for its header.
86
+ * @property {Parameters<typeof contextMenuHtml>[0]} [menu] A right-click menu over the page.
87
+ * @property {{x: number, y: number}} [pointer] The mouse pointer, in the content area.
88
+ */
89
+
90
+ /**
91
+ * The HTML of one store screenshot: the caption, Chrome's window with the extension's toolbar
92
+ * button, and the captures; a popup hangs from the button (shown pressed), a side panel sits at
93
+ * the window's right, a menu over the page.
94
+ *
95
+ * @param {Shot & {toolbarIcon: string, theme?: Theme, geo?: Geometry}} shot - What to draw; the extension's toolbar icon (a data: URL).
96
+ * @returns {string} A whole HTML document, {@link W}×{@link H}.
97
+ */
98
+ export function storeShotHtml({ caption, tabs, url, urlHost, page, popup, panel, menu, pointer, toolbarIcon, theme = 'light', geo = geometry(1) }) {
99
+ const { width: cw, height: ch } = geo.content;
100
+ const pageW = panel ? cw - PANEL_W : cw;
101
+ const content = `<div style="position:relative;display:flex;width:${cw}px;height:${ch}px">
102
+ <img src="${page}" width="${pageW}" height="${ch}" alt="" style="display:block;flex:none">
103
+ ${panel ? `<div class="panel"><div class="panel-head"><img src="${panel.icon}" width="16" height="16" alt=""><span>${esc(panel.title)}</span><svg viewBox="0 0 24 24" width="18" height="18"><path fill="#474747" d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></div>
104
+ <img src="${panel.image}" width="${PANEL_W}" height="${ch - PANEL_HEADER_H}" alt="" style="display:block"></div>` : ''}
105
+ ${menu ? contextMenuHtml(menu) : ''}
106
+ ${pointer ? cursor(pointer.x, pointer.y) : ''}
107
+ </div>
108
+ <style>
109
+ .panel { flex: none; width: ${PANEL_W}px; height: ${ch}px; background: #fff; border-left: 1px solid #d3d7dc; box-sizing: content-box; }
110
+ .panel-head { height: ${PANEL_HEADER_H}px; display: flex; align-items: center; gap: 10px; padding: 0 12px 0 14px; font: 500 13px system-ui; color: #1f1f1f; }
111
+ .panel-head span { flex: 1; }
112
+ </style>`;
113
+ const overlays = popup ? `<div class="popup" style="width:${popup.width}px;height:${popup.height}px"><img src="${popup.image}" width="${popup.width}" height="${popup.height}" alt=""></div>
114
+ <style>
115
+ .popup { position: absolute; z-index: 30; border-radius: 10px; overflow: hidden; background: #fff; box-shadow: 0 0 0 .5px rgba(0,0,0,.2), 0 4px 12px rgba(0,0,0,.14), 0 12px 32px rgba(0,0,0,.16); }
116
+ .popup img { display: block; }
117
+ .toolbar > img.pressed { border-radius: 50%; background: #e3e3e3; box-shadow: 0 0 0 9px #e3e3e3; }
118
+ </style>
119
+ <script>
120
+ // Hang the popup from the extension's button, as Chrome does: its right edge near the button's, just under the toolbar.
121
+ const button = document.querySelector('.toolbar > img');
122
+ button.classList.add('pressed');
123
+ const box = button.getBoundingClientRect();
124
+ const popupBox = document.querySelector('.popup');
125
+ popupBox.style.left = (box.right + 14 - ${popup.width}) + 'px';
126
+ popupBox.style.top = (${WIN.y + WIN.strip + WIN.toolbar} - 2) + 'px';
127
+ </script>` : '';
128
+ return canvas({ caption: esc(caption), theme, chrome: windowChrome({ tabs, url, urlHost, theme, w: geo.w, ext: [toolbarIcon] }), content, overlays, geo });
129
+ }
130
+
131
+ /**
132
+ * A data: URL of a PNG.
133
+ *
134
+ * @param {Uint8Array} png - The PNG's bytes.
135
+ * @returns {string} The URL.
136
+ */
137
+ export const pngUrl = (png) => `data:image/png;base64,${Buffer.from(png).toString('base64')}`;
138
+
139
+ /**
140
+ * A store-screenshot writer: lays each shot out with {@link storeShotHtml} in a page of the
141
+ * given browser at 2×, waits for its images, and saves it as a {@link W}×{@link H} PNG.
142
+ *
143
+ * @param {object} options - Where and how.
144
+ * @param {import('playwright').Browser | import('playwright').BrowserContext} options.browser - A browser (or a context) to draw in.
145
+ * @param {string} options.dir - The folder to write to (made if missing).
146
+ * @param {string} options.toolbarIcon - The extension's toolbar icon, a data: URL.
147
+ * @returns {{geo: Geometry, shoot: (file: string, shot: Shot) => Promise<string>}} The window's geometry
148
+ * (capture pages at `geo.content`), and `shoot`, which writes one and returns its path.
149
+ * @example
150
+ * const { geo, shoot } = storeShots({ browser, dir: 'store/screenshots', toolbarIcon });
151
+ * await shoot('1-menu.png', { caption: 'Copy any page as Markdown', tabs, url, page: pngUrl(await page.screenshot()) });
152
+ */
153
+ export function storeShots({ browser, dir, toolbarIcon }) {
154
+ const geo = geometry(1);
155
+ return {
156
+ geo,
157
+ shoot: async (file, shot) => {
158
+ const context = 'newContext' in browser ? await browser.newContext({ viewport: { width: W, height: H }, deviceScaleFactor: 2 }) : null;
159
+ const page = context ? await context.newPage() : await /** @type {import('playwright').BrowserContext} */ (browser).newPage();
160
+ try {
161
+ await page.setViewportSize({ width: W, height: H });
162
+ await page.setContent(storeShotHtml({ ...shot, toolbarIcon, geo }), { waitUntil: 'load' });
163
+ await page.evaluate(() => Promise.all([...document.images].map((img) => img.decode().catch(() => undefined))));
164
+ await mkdir(dir, { recursive: true });
165
+ const out = path.join(dir, file);
166
+ await writeFile(out, await page.screenshot({ type: 'png', scale: 'css', clip: { x: 0, y: 0, width: W, height: H } }));
167
+ return out;
168
+ } finally {
169
+ await (context ?? page).close();
170
+ }
171
+ },
172
+ };
173
+ }
@@ -54,6 +54,12 @@ export type TestBuild = {
54
54
  * Changes the copy's manifest in place.
55
55
  */
56
56
  manifest?: (manifest: TestManifest) => void;
57
+ /**
58
+ * A side panel page (`panel/panel.html`): adds {@link PANEL_OPENER}(`?tab=<id>`), whose button opens that panel beside the tab the way the toolbar click does
59
+ * (the kit's `openSidePanel`, during a real click), which Playwright can't click. Its title turns
60
+ * `opened` (or `error: …`). The kit must be vendored at `vendor/extension-kit/`.
61
+ */
62
+ panelOpener?: string;
57
63
  };
58
64
  /**
59
65
  * A Chrome extension manifest, as far as a test build changes it.
@@ -70,7 +76,13 @@ export type TestBuild = {
70
76
  * imports the real worker and exposes what a test can't reach headlessly (a menu click, the
71
77
  * toolbar button).
72
78
  * @property {(manifest: TestManifest) => void} [manifest] Changes the copy's manifest in place.
79
+ * @property {string} [panelOpener] A side panel page (`panel/panel.html`): adds {@link PANEL_OPENER}
80
+ * (`?tab=<id>`), whose button opens that panel beside the tab the way the toolbar click does
81
+ * (the kit's `openSidePanel`, during a real click), which Playwright can't click. Its title turns
82
+ * `opened` (or `error: …`). The kit must be vendored at `vendor/extension-kit/`.
73
83
  */
84
+ /** The page {@link TestBuild}'s `panelOpener` adds, from the build's root. */
85
+ export declare const PANEL_OPENER = "e2e/open-panel.html";
74
86
  /**
75
87
  * Copy an extension folder into a test build, with its own hook and manifest.
76
88
  *
@@ -82,4 +94,4 @@ export type TestBuild = {
82
94
  * manifest: (manifest) => { manifest.host_permissions = ['http://127.0.0.1/*']; },
83
95
  * });
84
96
  */
85
- export declare function buildTestCopy({ from, to, hook, manifest: change }: TestBuild): Promise<string>;
97
+ export declare function buildTestCopy({ from, to, hook, manifest: change, panelOpener }: TestBuild): Promise<string>;
@@ -11,7 +11,7 @@
11
11
  * @module build/test-build
12
12
  */
13
13
 
14
- import { cp, readFile, rm, writeFile } from 'node:fs/promises';
14
+ import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
15
15
  import path from 'node:path';
16
16
 
17
17
  /** The environment variable that moves the test builds' folder (absolute, or relative to the extension's root). */
@@ -53,8 +53,42 @@ export function testBuildDir(root, name = 'e2e', env = process.env) {
53
53
  * imports the real worker and exposes what a test can't reach headlessly (a menu click, the
54
54
  * toolbar button).
55
55
  * @property {(manifest: TestManifest) => void} [manifest] Changes the copy's manifest in place.
56
+ * @property {string} [panelOpener] A side panel page (`panel/panel.html`): adds {@link PANEL_OPENER}
57
+ * (`?tab=<id>`), whose button opens that panel beside the tab the way the toolbar click does
58
+ * (the kit's `openSidePanel`, during a real click), which Playwright can't click. Its title turns
59
+ * `opened` (or `error: …`). The kit must be vendored at `vendor/extension-kit/`.
56
60
  */
57
61
 
62
+ /** The page {@link TestBuild}'s `panelOpener` adds, from the build's root. */
63
+ export const PANEL_OPENER = 'e2e/open-panel.html';
64
+
65
+ /**
66
+ * The opener page's markup (its script a file beside it: extension pages run no inline script).
67
+ *
68
+ * @returns {string} The HTML.
69
+ */
70
+ const openerHtml = () => `<!doctype html>
71
+ <meta charset="utf-8">
72
+ <title>Open the side panel</title>
73
+ <!-- Test builds only: a button that does what the toolbar click does, which Playwright can't click. -->
74
+ <button id="open" type="button">Open the side panel</button>
75
+ <script type="module" src="open-panel.js"></script>
76
+ `;
77
+
78
+ /**
79
+ * The opener page's script.
80
+ *
81
+ * @param {string} panel - The panel page.
82
+ * @returns {string} The module.
83
+ */
84
+ const openerScript = (panel) => `import { openSidePanel } from '../vendor/extension-kit/panel/side-panel.js';
85
+
86
+ const tabId = Number(new URLSearchParams(location.search).get('tab'));
87
+ document.getElementById('open').addEventListener('click', () => {
88
+ openSidePanel(chrome.sidePanel, tabId, ${JSON.stringify(panel)}).then(() => { document.title = 'opened'; }, (error) => { document.title = \`error: \${error.message}\`; });
89
+ });
90
+ `;
91
+
58
92
  /**
59
93
  * Copy an extension folder into a test build, with its own hook and manifest.
60
94
  *
@@ -66,7 +100,7 @@ export function testBuildDir(root, name = 'e2e', env = process.env) {
66
100
  * manifest: (manifest) => { manifest.host_permissions = ['http://127.0.0.1/*']; },
67
101
  * });
68
102
  */
69
- export async function buildTestCopy({ from, to, hook, manifest: change }) {
103
+ export async function buildTestCopy({ from, to, hook, manifest: change, panelOpener }) {
70
104
  await rm(to, { recursive: true, force: true });
71
105
  await cp(from, to, { recursive: true });
72
106
  const file = path.join(to, 'manifest.json');
@@ -78,5 +112,10 @@ export async function buildTestCopy({ from, to, hook, manifest: change }) {
78
112
  }
79
113
  change?.(manifest);
80
114
  await writeFile(file, `${JSON.stringify(manifest, null, 2)}\n`);
115
+ if (panelOpener) {
116
+ await mkdir(path.join(to, path.dirname(PANEL_OPENER)), { recursive: true });
117
+ await writeFile(path.join(to, PANEL_OPENER), openerHtml());
118
+ await writeFile(path.join(to, PANEL_OPENER.replace(/\.html$/, '.js')), openerScript(panelOpener));
119
+ }
81
120
  return to;
82
121
  }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * A page's rendered elements, for a content script that reads them all: in
3
+ * document order, into open shadow roots and closed ones (where Chrome lets
4
+ * an extension see them, `chrome.dom.openOrClosedShadowRoot`), leaving out
5
+ * what Chrome doesn't render and what holds no page content (scripts,
6
+ * styles, templates, `<head>`).
7
+ *
8
+ * @module content/elements
9
+ */
10
+ /** The most elements read (a very long page's first ones). */
11
+ export declare const MAX_ELEMENTS = 20000;
12
+ /**
13
+ * An element's shadow root, closed ones too where Chrome lets the extension see them.
14
+ *
15
+ * @param {Element} element - The element.
16
+ * @returns {ShadowRoot | null} Its shadow root.
17
+ * @example
18
+ * const root = shadowRootOf(host); // a closed root, from a content script
19
+ */
20
+ export declare function shadowRootOf(element: Element): ShadowRoot | null;
21
+ /**
22
+ * Every rendered element from the body down, in document order, a shadow root's elements before
23
+ * its host's children. An element Chrome doesn't draw (`visibility: hidden`) is left out, but
24
+ * what's inside it is still read (a child can be visible again), as is what's inside a
25
+ * `display: contents` element (a slot), which has no box of its own; nothing inside a
26
+ * `display: none` or `content-visibility: hidden` part is.
27
+ *
28
+ * @param {Document} doc - The page.
29
+ * @param {{skip?: (element: Element) => boolean, limit?: number}} [options] - `skip`: elements left
30
+ * out with what's inside (the extension's overlay); `limit`: the most elements ({@link MAX_ELEMENTS}).
31
+ * @returns {Element[]} The elements.
32
+ * @example
33
+ * for (const element of renderedElements(document, { skip: isLayer })) read(element);
34
+ */
35
+ export declare function renderedElements(doc: Document, { skip, limit }?: {
36
+ skip?: (element: Element) => boolean;
37
+ limit?: number;
38
+ }): Element[];
39
+ /**
40
+ * The origins of a page's frames that are another site's: their content isn't the page's to read,
41
+ * and an extension reads them only with that site's access (`chrome.permissions.request`, from a
42
+ * click). Frames inside shadow roots are found too.
43
+ *
44
+ * @param {Document} doc - The page.
45
+ * @param {Element[]} [elements] - Its elements, when already listed ({@link renderedElements}).
46
+ * @returns {string[]} Their origins (`https://ads.example.net`), each once.
47
+ * @example
48
+ * otherSiteFrames(document); // ['https://www.youtube.com']
49
+ */
50
+ export declare function otherSiteFrames(doc: Document, elements?: Element[]): string[];