@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.
- package/README.md +145 -18
- package/bin/extension-kit.js +2 -1
- package/package.json +1 -1
- package/src/ai/host.d.ts +183 -0
- package/src/ai/host.js +225 -0
- package/src/ai/model.d.ts +2 -1
- package/src/ai/model.js +10 -2
- package/src/build/index.d.ts +2 -1
- package/src/build/index.js +2 -1
- package/src/build/store-shots.d.ts +177 -0
- package/src/build/store-shots.js +173 -0
- package/src/build/test-build.d.ts +13 -1
- package/src/build/test-build.js +41 -2
- package/src/build/vendor.d.ts +29 -8
- package/src/build/vendor.js +136 -40
- package/src/content/elements.d.ts +50 -0
- package/src/content/elements.js +88 -0
- package/src/content/highlight.d.ts +63 -0
- package/src/content/highlight.js +117 -0
- package/src/content/overlay.d.ts +31 -6
- package/src/content/overlay.js +56 -8
- package/src/content/page-toast.d.ts +26 -0
- package/src/content/page-toast.js +72 -0
- package/src/content/slices.d.ts +3 -1
- package/src/content/slices.js +29 -14
- package/src/content/watch.d.ts +4 -2
- package/src/content/watch.js +17 -3
- package/src/dom/copy.d.ts +36 -0
- package/src/dom/copy.js +84 -0
- package/src/dom/focus.d.ts +11 -0
- package/src/dom/focus.js +15 -0
- package/src/dom/i18n.d.ts +17 -0
- package/src/dom/i18n.js +15 -0
- package/src/dom/make.d.ts +53 -0
- package/src/dom/make.js +71 -0
- package/src/frame/window.d.ts +8 -1
- package/src/frame/window.js +11 -8
- package/src/index.d.ts +31 -6
- package/src/index.js +24 -6
- package/src/panel/show-on-page.d.ts +82 -0
- package/src/panel/show-on-page.js +83 -0
- package/src/scripting/call.d.ts +29 -9
- package/src/scripting/call.js +35 -11
- package/src/scripting/context-menus.d.ts +45 -0
- package/src/scripting/context-menus.js +53 -0
- package/src/scripting/registration.d.ts +2 -1
- package/src/scripting/registration.js +2 -1
- package/src/scripting/tab.d.ts +80 -0
- package/src/scripting/tab.js +89 -0
- package/src/settings/saver.js +16 -6
- package/src/settings/storage.d.ts +33 -2
- package/src/settings/storage.js +73 -3
- package/src/settings/store.d.ts +17 -0
- package/src/settings/store.js +87 -20
- package/src/sites/sites.d.ts +6 -1
- package/src/sites/sites.js +20 -3
- package/src/styles/chrome-ui.css +63 -30
- package/src/testing/e2e.d.ts +35 -4
- package/src/testing/e2e.js +67 -5
- package/src/testing/fake-model.d.ts +9 -1
- package/src/testing/fake-model.js +44 -23
- package/src/testing/fake-storage.d.ts +41 -13
- package/src/testing/fake-storage.js +163 -51
- package/src/testing/index.d.ts +8 -2
- package/src/testing/index.js +8 -2
- package/src/testing/live-run.d.ts +151 -0
- package/src/testing/live-run.js +206 -0
- package/src/testing/messages.d.ts +7 -3
- package/src/testing/messages.js +50 -9
- package/src/testing/package.d.ts +56 -7
- package/src/testing/package.js +57 -18
- package/src/testing/pages.d.ts +114 -0
- package/src/testing/pages.js +109 -0
- package/src/testing/prompt.d.ts +4 -2
- package/src/testing/prompt.js +7 -3
- package/src/time/pool.d.ts +2 -1
- package/src/time/pool.js +4 -2
- package/src/toolbar/theme.d.ts +10 -2
- package/src/toolbar/theme.js +12 -4
- package/src/ui/menu.d.ts +13 -3
- package/src/ui/menu.js +31 -11
- package/src/ui/model-row.js +5 -1
- package/src/ui/notice.d.ts +58 -0
- package/src/ui/notice.js +43 -0
- package/src/ui/site-list.js +12 -3
- package/src/ui/text-dialog.d.ts +3 -2
- package/src/ui/text-dialog.js +10 -3
- package/src/ui/toast.js +2 -0
package/src/build/test-build.js
CHANGED
|
@@ -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
|
}
|
package/src/build/vendor.d.ts
CHANGED
|
@@ -5,11 +5,13 @@
|
|
|
5
5
|
* `extension/vendor/`; the copy is made from the installed package on
|
|
6
6
|
* `npm install` (`postinstall`) and is never committed.
|
|
7
7
|
*
|
|
8
|
-
* Each copy is stamped with the package's name and version
|
|
9
|
-
* ({@link VENDOR_STAMP}), so {@link checkVendored} can say
|
|
10
|
-
* longer matches what's installed: `npm install some-package`
|
|
11
|
-
* without running the extension's own `postinstall`, and the copy
|
|
12
|
-
* without a word
|
|
8
|
+
* Each copy is stamped with the package's name and version, what was copied
|
|
9
|
+
* and a hash of it ({@link VENDOR_STAMP}), so {@link checkVendored} can say
|
|
10
|
+
* when a copy no longer matches what's installed: `npm install some-package`
|
|
11
|
+
* installs without running the extension's own `postinstall`, and the copy
|
|
12
|
+
* goes stale without a word; so does a copy edited by hand, or a package
|
|
13
|
+
* whose files changed under the same version (a library copied into
|
|
14
|
+
* `node_modules` to try it before it's published).
|
|
13
15
|
*
|
|
14
16
|
* An extension with a bundler imports the package instead and copies only
|
|
15
17
|
* the stylesheet ({@link STYLESHEET}).
|
|
@@ -31,6 +33,14 @@ export type VendorStamp = {
|
|
|
31
33
|
* Its version when copied.
|
|
32
34
|
*/
|
|
33
35
|
version: string;
|
|
36
|
+
/**
|
|
37
|
+
* The paths copied, as {@link vendorPackage} took them (the kit's own copy has none).
|
|
38
|
+
*/
|
|
39
|
+
files?: string[];
|
|
40
|
+
/**
|
|
41
|
+
* A hash of every file copied ({@link treeHash}); a copy from before 1.11 has none.
|
|
42
|
+
*/
|
|
43
|
+
hash?: string;
|
|
34
44
|
};
|
|
35
45
|
/**
|
|
36
46
|
* Where a vendored copy came from.
|
|
@@ -38,6 +48,8 @@ export type VendorStamp = {
|
|
|
38
48
|
* @typedef {object} VendorStamp
|
|
39
49
|
* @property {string} package The package's name.
|
|
40
50
|
* @property {string} version Its version when copied.
|
|
51
|
+
* @property {string[]} [files] The paths copied, as {@link vendorPackage} took them (the kit's own copy has none).
|
|
52
|
+
* @property {string} [hash] A hash of every file copied ({@link treeHash}); a copy from before 1.11 has none.
|
|
41
53
|
*/
|
|
42
54
|
/**
|
|
43
55
|
* The folder of an installed package, found as Node finds it (the nearest
|
|
@@ -55,6 +67,8 @@ export declare function packageDir(name: string, from?: string): Promise<string>
|
|
|
55
67
|
*
|
|
56
68
|
* @param {string} dest - The folder to fill, e.g. `extension/vendor/extension-kit`. Removed first.
|
|
57
69
|
* @returns {Promise<string[]>} The entries copied, relative to `dest`.
|
|
70
|
+
* @throws {Error} When `dest` holds an extension or a project (`manifest.json`, `package.json`, `.git`).
|
|
71
|
+
*
|
|
58
72
|
* @example
|
|
59
73
|
* await vendorKit('extension/vendor/extension-kit');
|
|
60
74
|
* // extension/vendor/extension-kit/index.js, …/styles/chrome-ui.css, …, vendored.json
|
|
@@ -71,7 +85,9 @@ export declare function vendorKit(dest: string): Promise<string[]>;
|
|
|
71
85
|
* @param {string} dest - The folder to fill, e.g. `extension/vendor/clean-link`. Removed first.
|
|
72
86
|
* @param {{from?: string}} [options] - `from`: where to look for `node_modules` from. Default the current folder.
|
|
73
87
|
* @returns {Promise<string[]>} The files and folders written, relative to `dest`.
|
|
74
|
-
* @throws {Error} When the package isn't installed,
|
|
88
|
+
* @throws {Error} When the package isn't installed, a path isn't in it, two paths would land in
|
|
89
|
+
* the same place (`dist/index.js` and `rules/index.js`), or `dest` holds an extension or a project.
|
|
90
|
+
*
|
|
75
91
|
* @example
|
|
76
92
|
* await vendorPackage('@duckduckgo/autoconsent', ['dist/autoconsent.esm.js', 'rules/compact-rules.json', 'LICENSE'], 'extension/vendor/autoconsent');
|
|
77
93
|
* // or, from postinstall: extension-kit vendor extension/vendor/clean-link @arraypress/clean-link src/
|
|
@@ -82,11 +98,16 @@ export declare function vendorPackage(name: string, files: readonly string[], de
|
|
|
82
98
|
/**
|
|
83
99
|
* Every vendored copy under `dir` (a folder with a {@link VENDOR_STAMP},
|
|
84
100
|
* searched to any depth) that no longer matches the package installed now:
|
|
85
|
-
* copied from another version,
|
|
101
|
+
* copied from another version, from a package that's gone, edited since (a
|
|
102
|
+
* file a script wrote beside the copied ones afterwards doesn't count), or
|
|
103
|
+
* from files the installed package no longer has as they were. A folder with
|
|
104
|
+
* no vendored copy at all (a mistyped path, `postinstall` never run) is
|
|
105
|
+
* reported too.
|
|
86
106
|
*
|
|
87
107
|
* @param {string} dir - The vendor folder, e.g. `extension/vendor`.
|
|
88
108
|
* @param {{from?: string}} [options] - `from`: where to look for `node_modules` from. Default the current folder.
|
|
89
|
-
* @returns {Promise<string[]>} One line per stale copy; empty when all match
|
|
109
|
+
* @returns {Promise<string[]>} One line per stale copy; empty when all match.
|
|
110
|
+
*
|
|
90
111
|
* @example
|
|
91
112
|
* const stale = await checkVendored('extension/vendor');
|
|
92
113
|
* // ['extension/vendor/extension-kit is @arraypress/extension-kit 1.2.0; 1.3.0 is installed']
|
package/src/build/vendor.js
CHANGED
|
@@ -5,11 +5,13 @@
|
|
|
5
5
|
* `extension/vendor/`; the copy is made from the installed package on
|
|
6
6
|
* `npm install` (`postinstall`) and is never committed.
|
|
7
7
|
*
|
|
8
|
-
* Each copy is stamped with the package's name and version
|
|
9
|
-
* ({@link VENDOR_STAMP}), so {@link checkVendored} can say
|
|
10
|
-
* longer matches what's installed: `npm install some-package`
|
|
11
|
-
* without running the extension's own `postinstall`, and the copy
|
|
12
|
-
* without a word
|
|
8
|
+
* Each copy is stamped with the package's name and version, what was copied
|
|
9
|
+
* and a hash of it ({@link VENDOR_STAMP}), so {@link checkVendored} can say
|
|
10
|
+
* when a copy no longer matches what's installed: `npm install some-package`
|
|
11
|
+
* installs without running the extension's own `postinstall`, and the copy
|
|
12
|
+
* goes stale without a word; so does a copy edited by hand, or a package
|
|
13
|
+
* whose files changed under the same version (a library copied into
|
|
14
|
+
* `node_modules` to try it before it's published).
|
|
13
15
|
*
|
|
14
16
|
* An extension with a bundler imports the package instead and copies only
|
|
15
17
|
* the stylesheet ({@link STYLESHEET}).
|
|
@@ -17,7 +19,8 @@
|
|
|
17
19
|
* @module build/vendor
|
|
18
20
|
*/
|
|
19
21
|
|
|
20
|
-
import {
|
|
22
|
+
import { createHash } from 'node:crypto';
|
|
23
|
+
import { access, copyFile, mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
|
|
21
24
|
import { fileURLToPath } from 'node:url';
|
|
22
25
|
import path from 'node:path';
|
|
23
26
|
|
|
@@ -42,6 +45,8 @@ export const VENDOR_STAMP = 'vendored.json';
|
|
|
42
45
|
* @typedef {object} VendorStamp
|
|
43
46
|
* @property {string} package The package's name.
|
|
44
47
|
* @property {string} version Its version when copied.
|
|
48
|
+
* @property {string[]} [files] The paths copied, as {@link vendorPackage} took them (the kit's own copy has none).
|
|
49
|
+
* @property {string} [hash] A hash of every file copied ({@link treeHash}); a copy from before 1.11 has none.
|
|
45
50
|
*/
|
|
46
51
|
|
|
47
52
|
/**
|
|
@@ -73,14 +78,90 @@ async function versionOf(dir) {
|
|
|
73
78
|
}
|
|
74
79
|
|
|
75
80
|
/**
|
|
76
|
-
*
|
|
81
|
+
* Every file under a file or folder.
|
|
77
82
|
*
|
|
78
|
-
* @param {string}
|
|
79
|
-
* @
|
|
83
|
+
* @param {string} source - A file or a folder.
|
|
84
|
+
* @returns {Promise<string[]>} Paths from `source`, forward slashes ('' for a file itself).
|
|
85
|
+
*/
|
|
86
|
+
async function filesOf(source) {
|
|
87
|
+
if (!(await stat(source)).isDirectory()) return [''];
|
|
88
|
+
const entries = await readdir(source, { recursive: true, withFileTypes: true });
|
|
89
|
+
return entries.filter((entry) => entry.isFile()).map((entry) => path.relative(source, path.join(entry.parentPath, entry.name)).split(path.sep).join('/'));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* What a copy holds: each file's place in the vendored folder and the file it comes from. A
|
|
94
|
+
* path lands under its own name (`dist/lib.esm.js` → `lib.esm.js`); a path ending in `/` has its
|
|
95
|
+
* contents copied instead (`src/` → `index.js`, …).
|
|
96
|
+
*
|
|
97
|
+
* @param {string} dir - The folder the paths are in (a package's, or the kit's `src/`).
|
|
98
|
+
* @param {readonly string[]} files - The paths.
|
|
99
|
+
* @returns {Promise<Map<string, string>>} Place in the copy (forward slashes) → source file.
|
|
100
|
+
* @throws {Error} When a path isn't there, or two files would land in the same place.
|
|
101
|
+
*/
|
|
102
|
+
async function copyPlan(dir, files) {
|
|
103
|
+
/** @type {Map<string, string>} */
|
|
104
|
+
const plan = new Map();
|
|
105
|
+
for (const file of files) {
|
|
106
|
+
const source = path.join(dir, file);
|
|
107
|
+
const under = file.endsWith('/') ? '' : path.basename(file);
|
|
108
|
+
for (const inner of await filesOf(source)) {
|
|
109
|
+
const place = [under, inner].filter(Boolean).join('/');
|
|
110
|
+
const taken = plan.get(place);
|
|
111
|
+
if (taken) throw new Error(`${path.relative(dir, taken)} and ${path.relative(dir, path.join(source, inner))} would both be copied to ${place}`);
|
|
112
|
+
plan.set(place, path.join(source, inner));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return plan;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* A hash of a set of files: their places and contents, in order of place.
|
|
120
|
+
*
|
|
121
|
+
* @param {Iterable<[string, string]>} files - Place (forward slashes) → the file to read.
|
|
122
|
+
* @returns {Promise<string>} SHA-256, hex.
|
|
123
|
+
*/
|
|
124
|
+
async function treeHash(files) {
|
|
125
|
+
const hash = createHash('sha256');
|
|
126
|
+
for (const [place, file] of [...files].sort(([a], [b]) => (a < b ? -1 : 1))) {
|
|
127
|
+
hash.update(`${place}\0`).update(await readFile(file)).update('\0');
|
|
128
|
+
}
|
|
129
|
+
return hash.digest('hex');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Whether a folder is somewhere a copy mustn't go: it would be deleted first, and it holds an
|
|
134
|
+
* extension or a project (`manifest.json`, `package.json`, a `.git`) rather than an older copy.
|
|
135
|
+
*
|
|
136
|
+
* @param {string} dest - The folder to fill.
|
|
137
|
+
* @returns {Promise<void>}
|
|
138
|
+
* @throws {Error} When it does.
|
|
139
|
+
*/
|
|
140
|
+
async function refuseSource(dest) {
|
|
141
|
+
for (const sign of ['manifest.json', 'package.json', '.git']) {
|
|
142
|
+
if (await access(path.join(dest, sign)).then(() => true, () => false)) {
|
|
143
|
+
throw new Error(`${dest} holds a ${sign}: it isn't a vendor folder, and copying into it would delete it first`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Replace `dest` with the files of a plan, stamped.
|
|
150
|
+
*
|
|
151
|
+
* @param {string} dest - The vendored folder. Removed first.
|
|
152
|
+
* @param {Map<string, string>} plan - From {@link copyPlan}.
|
|
153
|
+
* @param {Omit<VendorStamp, 'hash'>} source - Where the copy comes from.
|
|
80
154
|
* @returns {Promise<void>}
|
|
81
155
|
*/
|
|
82
|
-
async function
|
|
83
|
-
await
|
|
156
|
+
async function copyStamped(dest, plan, source) {
|
|
157
|
+
await refuseSource(dest);
|
|
158
|
+
await rm(dest, { recursive: true, force: true });
|
|
159
|
+
await mkdir(dest, { recursive: true });
|
|
160
|
+
for (const [place, file] of plan) {
|
|
161
|
+
await mkdir(path.dirname(path.join(dest, place)), { recursive: true });
|
|
162
|
+
await copyFile(file, path.join(dest, place));
|
|
163
|
+
}
|
|
164
|
+
await writeFile(path.join(dest, VENDOR_STAMP), `${JSON.stringify({ ...source, hash: await treeHash(plan) }, null, 2)}\n`);
|
|
84
165
|
}
|
|
85
166
|
|
|
86
167
|
/**
|
|
@@ -88,17 +169,14 @@ async function stamp(dest, source) {
|
|
|
88
169
|
*
|
|
89
170
|
* @param {string} dest - The folder to fill, e.g. `extension/vendor/extension-kit`. Removed first.
|
|
90
171
|
* @returns {Promise<string[]>} The entries copied, relative to `dest`.
|
|
172
|
+
* @throws {Error} When `dest` holds an extension or a project (`manifest.json`, `package.json`, `.git`).
|
|
173
|
+
*
|
|
91
174
|
* @example
|
|
92
175
|
* await vendorKit('extension/vendor/extension-kit');
|
|
93
176
|
* // extension/vendor/extension-kit/index.js, …/styles/chrome-ui.css, …, vendored.json
|
|
94
177
|
*/
|
|
95
178
|
export async function vendorKit(dest) {
|
|
96
|
-
await
|
|
97
|
-
await mkdir(dest, { recursive: true });
|
|
98
|
-
for (const entry of RUNTIME_ENTRIES) {
|
|
99
|
-
await cp(path.join(SOURCE, entry), path.join(dest, entry), { recursive: true });
|
|
100
|
-
}
|
|
101
|
-
await stamp(dest, { package: KIT, version: await versionOf(path.dirname(SOURCE)) });
|
|
179
|
+
await copyStamped(dest, await copyPlan(SOURCE, RUNTIME_ENTRIES), { package: KIT, version: await versionOf(path.dirname(SOURCE)) });
|
|
102
180
|
return [...RUNTIME_ENTRIES];
|
|
103
181
|
}
|
|
104
182
|
|
|
@@ -113,54 +191,72 @@ export async function vendorKit(dest) {
|
|
|
113
191
|
* @param {string} dest - The folder to fill, e.g. `extension/vendor/clean-link`. Removed first.
|
|
114
192
|
* @param {{from?: string}} [options] - `from`: where to look for `node_modules` from. Default the current folder.
|
|
115
193
|
* @returns {Promise<string[]>} The files and folders written, relative to `dest`.
|
|
116
|
-
* @throws {Error} When the package isn't installed,
|
|
194
|
+
* @throws {Error} When the package isn't installed, a path isn't in it, two paths would land in
|
|
195
|
+
* the same place (`dist/index.js` and `rules/index.js`), or `dest` holds an extension or a project.
|
|
196
|
+
*
|
|
117
197
|
* @example
|
|
118
198
|
* await vendorPackage('@duckduckgo/autoconsent', ['dist/autoconsent.esm.js', 'rules/compact-rules.json', 'LICENSE'], 'extension/vendor/autoconsent');
|
|
119
199
|
* // or, from postinstall: extension-kit vendor extension/vendor/clean-link @arraypress/clean-link src/
|
|
120
200
|
*/
|
|
121
201
|
export async function vendorPackage(name, files, dest, { from } = {}) {
|
|
122
202
|
const dir = await packageDir(name, from);
|
|
123
|
-
|
|
124
|
-
await
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
203
|
+
const plan = await copyPlan(dir, files);
|
|
204
|
+
await copyStamped(dest, plan, { package: name, version: await versionOf(dir), files: [...files] });
|
|
205
|
+
return [...new Set([...plan.keys()].map((place) => place.split('/')[0]))];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Why a stamped copy no longer matches the package installed now, if it doesn't.
|
|
210
|
+
*
|
|
211
|
+
* @param {string} folder - The vendored folder.
|
|
212
|
+
* @param {VendorStamp} copied - Its stamp.
|
|
213
|
+
* @param {string | undefined} from - Where to look for `node_modules` from.
|
|
214
|
+
* @returns {Promise<string | null>} The line to print, or null when it matches.
|
|
215
|
+
*/
|
|
216
|
+
async function staleness(folder, copied, from) {
|
|
217
|
+
const dir = await packageDir(copied.package, from).catch(() => null);
|
|
218
|
+
if (dir === null) return `${folder} is ${copied.package} ${copied.version}, which isn't installed`;
|
|
219
|
+
const installed = await versionOf(dir);
|
|
220
|
+
if (installed !== copied.version) return `${folder} is ${copied.package} ${copied.version}; ${installed} is installed`;
|
|
221
|
+
if (!copied.hash) return null;
|
|
222
|
+
const plan = await (copied.files ? copyPlan(dir, copied.files) : copyPlan(path.join(dir, 'src'), RUNTIME_ENTRIES)).catch(() => null);
|
|
223
|
+
if (plan === null || await treeHash(plan) !== copied.hash) {
|
|
224
|
+
return `${folder} is ${copied.package} ${copied.version}, but the installed ${copied.version}'s files are different`;
|
|
136
225
|
}
|
|
137
|
-
|
|
138
|
-
|
|
226
|
+
// The copied files as they are now; files a script wrote beside them afterwards aren't the package's.
|
|
227
|
+
const here = await treeHash([...plan.keys()].map((place) => [place, path.join(folder, place)])).catch(() => null);
|
|
228
|
+
if (here !== copied.hash) return `${folder} has changed since it was copied from ${copied.package} ${copied.version}`;
|
|
229
|
+
return null;
|
|
139
230
|
}
|
|
140
231
|
|
|
141
232
|
/**
|
|
142
233
|
* Every vendored copy under `dir` (a folder with a {@link VENDOR_STAMP},
|
|
143
234
|
* searched to any depth) that no longer matches the package installed now:
|
|
144
|
-
* copied from another version,
|
|
235
|
+
* copied from another version, from a package that's gone, edited since (a
|
|
236
|
+
* file a script wrote beside the copied ones afterwards doesn't count), or
|
|
237
|
+
* from files the installed package no longer has as they were. A folder with
|
|
238
|
+
* no vendored copy at all (a mistyped path, `postinstall` never run) is
|
|
239
|
+
* reported too.
|
|
145
240
|
*
|
|
146
241
|
* @param {string} dir - The vendor folder, e.g. `extension/vendor`.
|
|
147
242
|
* @param {{from?: string}} [options] - `from`: where to look for `node_modules` from. Default the current folder.
|
|
148
|
-
* @returns {Promise<string[]>} One line per stale copy; empty when all match
|
|
243
|
+
* @returns {Promise<string[]>} One line per stale copy; empty when all match.
|
|
244
|
+
*
|
|
149
245
|
* @example
|
|
150
246
|
* const stale = await checkVendored('extension/vendor');
|
|
151
247
|
* // ['extension/vendor/extension-kit is @arraypress/extension-kit 1.2.0; 1.3.0 is installed']
|
|
152
248
|
*/
|
|
153
249
|
export async function checkVendored(dir, { from } = {}) {
|
|
154
250
|
const entries = await readdir(dir, { recursive: true }).catch(() => /** @type {string[]} */ ([]));
|
|
251
|
+
const stamps = entries.filter((file) => path.basename(file) === VENDOR_STAMP).sort();
|
|
252
|
+
if (!stamps.length) return [`${dir} has no vendored copies (no ${VENDOR_STAMP} in it)`];
|
|
155
253
|
/** @type {string[]} */
|
|
156
254
|
const stale = [];
|
|
157
|
-
for (const entry of
|
|
158
|
-
const folder = path.join(dir, path.dirname(entry));
|
|
255
|
+
for (const entry of stamps) {
|
|
159
256
|
/** @type {VendorStamp} */
|
|
160
257
|
const copied = JSON.parse(await readFile(path.join(dir, entry), 'utf8'));
|
|
161
|
-
const
|
|
162
|
-
if (
|
|
163
|
-
else if (installed !== copied.version) stale.push(`${folder} is ${copied.package} ${copied.version}; ${installed} is installed`);
|
|
258
|
+
const line = await staleness(path.join(dir, path.dirname(entry)), copied, from);
|
|
259
|
+
if (line) stale.push(line);
|
|
164
260
|
}
|
|
165
261
|
return stale;
|
|
166
262
|
}
|
|
@@ -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[];
|
|
@@ -0,0 +1,88 @@
|
|
|
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
|
+
|
|
11
|
+
/** Elements that are never drawn as the page's content, nor anything inside them. */
|
|
12
|
+
const NEVER_RENDERED = new Set(['script', 'style', 'noscript', 'template', 'head', 'title', 'meta', 'link']);
|
|
13
|
+
|
|
14
|
+
/** The most elements read (a very long page's first ones). */
|
|
15
|
+
export const MAX_ELEMENTS = 20_000;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* An element's shadow root, closed ones too where Chrome lets the extension see them.
|
|
19
|
+
*
|
|
20
|
+
* @param {Element} element - The element.
|
|
21
|
+
* @returns {ShadowRoot | null} Its shadow root.
|
|
22
|
+
* @example
|
|
23
|
+
* const root = shadowRootOf(host); // a closed root, from a content script
|
|
24
|
+
*/
|
|
25
|
+
export function shadowRootOf(element) {
|
|
26
|
+
const dom = /** @type {{dom?: {openOrClosedShadowRoot?: (element: HTMLElement) => ShadowRoot | null}}} */ (globalThis.chrome ?? {}).dom;
|
|
27
|
+
return (dom?.openOrClosedShadowRoot && element instanceof HTMLElement ? dom.openOrClosedShadowRoot(element) : element.shadowRoot) ?? null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Every rendered element from the body down, in document order, a shadow root's elements before
|
|
32
|
+
* its host's children. An element Chrome doesn't draw (`visibility: hidden`) is left out, but
|
|
33
|
+
* what's inside it is still read (a child can be visible again), as is what's inside a
|
|
34
|
+
* `display: contents` element (a slot), which has no box of its own; nothing inside a
|
|
35
|
+
* `display: none` or `content-visibility: hidden` part is.
|
|
36
|
+
*
|
|
37
|
+
* @param {Document} doc - The page.
|
|
38
|
+
* @param {{skip?: (element: Element) => boolean, limit?: number}} [options] - `skip`: elements left
|
|
39
|
+
* out with what's inside (the extension's overlay); `limit`: the most elements ({@link MAX_ELEMENTS}).
|
|
40
|
+
* @returns {Element[]} The elements.
|
|
41
|
+
* @example
|
|
42
|
+
* for (const element of renderedElements(document, { skip: isLayer })) read(element);
|
|
43
|
+
*/
|
|
44
|
+
export function renderedElements(doc, { skip = () => false, limit = MAX_ELEMENTS } = {}) {
|
|
45
|
+
/** @type {Element[]} */
|
|
46
|
+
const found = [];
|
|
47
|
+
/** @param {Element} element */
|
|
48
|
+
const visit = (element) => {
|
|
49
|
+
if (found.length >= limit || NEVER_RENDERED.has(element.localName) || skip(element)) return;
|
|
50
|
+
if (element.checkVisibility()) {
|
|
51
|
+
if (element.checkVisibility({ visibilityProperty: true })) found.push(element);
|
|
52
|
+
} else if (element.ownerDocument.defaultView?.getComputedStyle(element).display !== 'contents') {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const shadow = shadowRootOf(element);
|
|
56
|
+
if (shadow) for (const child of shadow.children) visit(child);
|
|
57
|
+
for (const child of element.children) visit(child);
|
|
58
|
+
};
|
|
59
|
+
if (doc.body) visit(doc.body);
|
|
60
|
+
return found;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The origins of a page's frames that are another site's: their content isn't the page's to read,
|
|
65
|
+
* and an extension reads them only with that site's access (`chrome.permissions.request`, from a
|
|
66
|
+
* click). Frames inside shadow roots are found too.
|
|
67
|
+
*
|
|
68
|
+
* @param {Document} doc - The page.
|
|
69
|
+
* @param {Element[]} [elements] - Its elements, when already listed ({@link renderedElements}).
|
|
70
|
+
* @returns {string[]} Their origins (`https://ads.example.net`), each once.
|
|
71
|
+
* @example
|
|
72
|
+
* otherSiteFrames(document); // ['https://www.youtube.com']
|
|
73
|
+
*/
|
|
74
|
+
export function otherSiteFrames(doc, elements = renderedElements(doc)) {
|
|
75
|
+
const own = doc.location?.origin;
|
|
76
|
+
/** @type {Set<string>} */
|
|
77
|
+
const sites = new Set();
|
|
78
|
+
for (const frame of elements) {
|
|
79
|
+
if (frame.localName !== 'iframe' && frame.localName !== 'frame') continue;
|
|
80
|
+
try {
|
|
81
|
+
const url = new URL(/** @type {HTMLIFrameElement} */ (frame).src);
|
|
82
|
+
if (/^https?:$/.test(url.protocol) && url.origin !== own) sites.add(url.origin);
|
|
83
|
+
} catch {
|
|
84
|
+
// No address (srcdoc, about:blank): the page's own.
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return [...sites];
|
|
88
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* “Show on page”: boxes in Chrome's blue (DevTools' highlight) around a set of
|
|
3
|
+
* elements, kept on them as the page scrolls or resizes, until cleared (the
|
|
4
|
+
* extension's button again, another set, Esc, or the page being left) and
|
|
5
|
+
* `onClear` is told which, so a panel's pressed button can follow an Esc on
|
|
6
|
+
* the page. The first can be scrolled into view. The boxes go in the extension's own overlay (`pageOverlay`), so
|
|
7
|
+
* the page's styles can't touch them.
|
|
8
|
+
*
|
|
9
|
+
* @module content/highlight
|
|
10
|
+
*/
|
|
11
|
+
/** The most elements boxed at once. */
|
|
12
|
+
export declare const MAX_BOXES = 500;
|
|
13
|
+
/** The boxes' styles, for the overlay's stylesheet. @type {string} */
|
|
14
|
+
export declare const HIGHLIGHT_CSS: string;
|
|
15
|
+
export type Highlighter = {
|
|
16
|
+
/**
|
|
17
|
+
* Box elements (the
|
|
18
|
+
* ones still in the page, the first {@link MAX_BOXES}), clearing any boxed before; `reveal`
|
|
19
|
+
* scrolls the first into view. Returns how many are boxed.
|
|
20
|
+
*/
|
|
21
|
+
show: (elements: Element[], options?: {
|
|
22
|
+
reveal?: boolean;
|
|
23
|
+
}) => number;
|
|
24
|
+
/**
|
|
25
|
+
* Take the boxes away (`onClear('asked')`).
|
|
26
|
+
*/
|
|
27
|
+
clear: () => void;
|
|
28
|
+
};
|
|
29
|
+
export type ClearReason = 'escape' | 'replaced' | 'asked' | 'pagehide';
|
|
30
|
+
/**
|
|
31
|
+
* A page's highlight: one set of elements boxed at a time.
|
|
32
|
+
*
|
|
33
|
+
* @typedef {object} Highlighter
|
|
34
|
+
* @property {(elements: Element[], options?: {reveal?: boolean}) => number} show Box elements (the
|
|
35
|
+
* ones still in the page, the first {@link MAX_BOXES}), clearing any boxed before; `reveal`
|
|
36
|
+
* scrolls the first into view. Returns how many are boxed.
|
|
37
|
+
* @property {() => void} clear Take the boxes away (`onClear('asked')`).
|
|
38
|
+
*/
|
|
39
|
+
/**
|
|
40
|
+
* Why the boxes went: Esc on the page, another set shown in their place, `clear()` called, or
|
|
41
|
+
* the page left (`pagehide`: a page kept for Back would come back with stale boxes).
|
|
42
|
+
*
|
|
43
|
+
* @typedef {'escape' | 'replaced' | 'asked' | 'pagehide'} ClearReason
|
|
44
|
+
*/
|
|
45
|
+
/**
|
|
46
|
+
* A highlighter for a page.
|
|
47
|
+
*
|
|
48
|
+
* @param {Document} doc - The page.
|
|
49
|
+
* @param {object} options
|
|
50
|
+
* @param {() => ParentNode} options.root - Where boxes go: the overlay's shadow root, made when first needed.
|
|
51
|
+
* @param {(reason: ClearReason) => void} [options.onClear] - After the boxes go, and why (take an empty
|
|
52
|
+
* overlay away; tell the panel when it's `'escape'` or `'pagehide'`, which it didn't ask for).
|
|
53
|
+
* @param {number} [options.max] - The most elements boxed. Default {@link MAX_BOXES}.
|
|
54
|
+
* @returns {Highlighter} It.
|
|
55
|
+
* @example
|
|
56
|
+
* const highlight = createHighlighter(document, { root: () => layer(document), onClear: tidyLayer });
|
|
57
|
+
* highlight.show(elements, { reveal: true });
|
|
58
|
+
*/
|
|
59
|
+
export declare function createHighlighter(doc: Document, { root, onClear, max }: {
|
|
60
|
+
root: () => ParentNode;
|
|
61
|
+
onClear?: (reason: ClearReason) => void;
|
|
62
|
+
max?: number;
|
|
63
|
+
}): Highlighter;
|