@arraypress/extension-kit 1.2.0 → 1.4.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 +147 -17
- package/bin/extension-kit.js +46 -10
- package/package.json +1 -1
- package/src/build/index.d.ts +4 -2
- package/src/build/index.js +6 -2
- package/src/build/store-frame.d.ts +176 -0
- package/src/build/store-frame.js +241 -0
- package/src/build/test-build.d.ts +85 -0
- package/src/build/test-build.js +82 -0
- package/src/build/toolbar-icons.d.ts +36 -18
- package/src/build/toolbar-icons.js +91 -24
- package/src/build/vendor.d.ts +78 -8
- package/src/build/vendor.js +133 -10
- package/src/build/zip.d.ts +3 -2
- package/src/build/zip.js +6 -4
- package/src/dom/focus.d.ts +28 -0
- package/src/dom/focus.js +36 -0
- package/src/dom/i18n.d.ts +11 -3
- package/src/dom/i18n.js +17 -4
- package/src/dom/setup.d.ts +55 -0
- package/src/dom/setup.js +47 -0
- package/src/index.d.ts +9 -1
- package/src/index.js +10 -1
- package/src/sites/sites.d.ts +114 -0
- package/src/sites/sites.js +169 -0
- package/src/styles/chrome-ui.css +13 -6
- package/src/testing/browser.d.ts +52 -0
- package/src/testing/browser.js +79 -0
- package/src/testing/e2e.d.ts +18 -5
- package/src/testing/e2e.js +17 -5
- package/src/testing/index.d.ts +7 -3
- package/src/testing/index.js +9 -3
- package/src/testing/pool.d.ts +23 -0
- package/src/testing/pool.js +33 -0
- package/src/time/time.d.ts +36 -0
- package/src/time/time.js +47 -0
- package/src/ui/menu.d.ts +13 -6
- package/src/ui/menu.js +11 -7
- package/src/ui/site-list.d.ts +216 -0
- package/src/ui/site-list.js +234 -0
- package/src/ui/text-dialog.js +3 -1
- package/src/ui/toast.d.ts +25 -9
- package/src/ui/toast.js +25 -11
package/src/build/vendor.d.ts
CHANGED
|
@@ -1,26 +1,96 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Copy
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* copy is made from the installed package on
|
|
6
|
-
*
|
|
2
|
+
* Copy browser code from npm packages into an extension folder. A packaged
|
|
3
|
+
* extension can't load files from outside itself, so an extension without a
|
|
4
|
+
* bundler loads the kit (and any other library) from a copy in
|
|
5
|
+
* `extension/vendor/`; the copy is made from the installed package on
|
|
6
|
+
* `npm install` (`postinstall`) and is never committed.
|
|
7
|
+
*
|
|
8
|
+
* Each copy is stamped with the package's name and version
|
|
9
|
+
* ({@link VENDOR_STAMP}), so {@link checkVendored} can say when a copy no
|
|
10
|
+
* longer matches what's installed: `npm install some-package` installs
|
|
11
|
+
* without running the extension's own `postinstall`, and the copy goes stale
|
|
12
|
+
* without a word.
|
|
7
13
|
*
|
|
8
14
|
* An extension with a bundler imports the package instead and copies only
|
|
9
15
|
* the stylesheet ({@link STYLESHEET}).
|
|
10
16
|
*
|
|
11
17
|
* @module build/vendor
|
|
12
18
|
*/
|
|
13
|
-
/** What a vendored copy holds, relative to `src/`: everything an extension page or worker may load. */
|
|
19
|
+
/** What a vendored copy of the kit holds, relative to `src/`: everything an extension page or worker may load. */
|
|
14
20
|
export declare const RUNTIME_ENTRIES: readonly string[];
|
|
15
21
|
/** The kit's stylesheet, an absolute path in the installed package. */
|
|
16
22
|
export declare const STYLESHEET: string;
|
|
23
|
+
/** The file in each vendored folder that names the package and version it was copied from (left out of the zip). */
|
|
24
|
+
export declare const VENDOR_STAMP = "vendored.json";
|
|
25
|
+
export type VendorStamp = {
|
|
26
|
+
/**
|
|
27
|
+
* The package's name.
|
|
28
|
+
*/
|
|
29
|
+
package: string;
|
|
30
|
+
/**
|
|
31
|
+
* Its version when copied.
|
|
32
|
+
*/
|
|
33
|
+
version: string;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Where a vendored copy came from.
|
|
37
|
+
*
|
|
38
|
+
* @typedef {object} VendorStamp
|
|
39
|
+
* @property {string} package The package's name.
|
|
40
|
+
* @property {string} version Its version when copied.
|
|
41
|
+
*/
|
|
17
42
|
/**
|
|
18
|
-
*
|
|
43
|
+
* The folder of an installed package, found as Node finds it (the nearest
|
|
44
|
+
* `node_modules/<name>` up from `from`), read from disk so a package whose
|
|
45
|
+
* `exports` hide its `package.json` is found too.
|
|
46
|
+
*
|
|
47
|
+
* @param {string} name - The package's name, e.g. `@duckduckgo/autoconsent`.
|
|
48
|
+
* @param {string} [from] - Where to look up from. Default the current folder.
|
|
49
|
+
* @returns {Promise<string>} The package's folder.
|
|
50
|
+
* @throws {Error} When it isn't installed.
|
|
51
|
+
*/
|
|
52
|
+
export declare function packageDir(name: string, from?: string): Promise<string>;
|
|
53
|
+
/**
|
|
54
|
+
* Replace `dest` with a fresh copy of the kit's runtime modules and stylesheet.
|
|
19
55
|
*
|
|
20
56
|
* @param {string} dest - The folder to fill, e.g. `extension/vendor/extension-kit`. Removed first.
|
|
21
57
|
* @returns {Promise<string[]>} The entries copied, relative to `dest`.
|
|
22
58
|
* @example
|
|
23
59
|
* await vendorKit('extension/vendor/extension-kit');
|
|
24
|
-
* // extension/vendor/extension-kit/index.js, …/styles/chrome-ui.css,
|
|
60
|
+
* // extension/vendor/extension-kit/index.js, …/styles/chrome-ui.css, …, vendored.json
|
|
25
61
|
*/
|
|
26
62
|
export declare function vendorKit(dest: string): Promise<string[]>;
|
|
63
|
+
/**
|
|
64
|
+
* Replace `dest` with named files and folders of an installed npm package.
|
|
65
|
+
* Each path is relative to the package's folder and lands in `dest` under
|
|
66
|
+
* its own name (`dist/lib.esm.js` → `dest/lib.esm.js`); a path ending in `/`
|
|
67
|
+
* has its contents copied into `dest` instead (`src/` → `dest/index.js`, …).
|
|
68
|
+
*
|
|
69
|
+
* @param {string} name - The package, as installed (`@arraypress/clean-link`).
|
|
70
|
+
* @param {readonly string[]} files - What to copy.
|
|
71
|
+
* @param {string} dest - The folder to fill, e.g. `extension/vendor/clean-link`. Removed first.
|
|
72
|
+
* @param {{from?: string}} [options] - `from`: where to look for `node_modules` from. Default the current folder.
|
|
73
|
+
* @returns {Promise<string[]>} The files and folders written, relative to `dest`.
|
|
74
|
+
* @throws {Error} When the package isn't installed, or a path isn't in it.
|
|
75
|
+
* @example
|
|
76
|
+
* await vendorPackage('@duckduckgo/autoconsent', ['dist/autoconsent.esm.js', 'rules/compact-rules.json', 'LICENSE'], 'extension/vendor/autoconsent');
|
|
77
|
+
* // or, from postinstall: extension-kit vendor extension/vendor/clean-link @arraypress/clean-link src/
|
|
78
|
+
*/
|
|
79
|
+
export declare function vendorPackage(name: string, files: readonly string[], dest: string, { from }?: {
|
|
80
|
+
from?: string;
|
|
81
|
+
}): Promise<string[]>;
|
|
82
|
+
/**
|
|
83
|
+
* Every vendored copy under `dir` (a folder with a {@link VENDOR_STAMP},
|
|
84
|
+
* searched to any depth) that no longer matches the package installed now:
|
|
85
|
+
* copied from another version, or from a package that's gone.
|
|
86
|
+
*
|
|
87
|
+
* @param {string} dir - The vendor folder, e.g. `extension/vendor`.
|
|
88
|
+
* @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 (or there are none).
|
|
90
|
+
* @example
|
|
91
|
+
* const stale = await checkVendored('extension/vendor');
|
|
92
|
+
* // ['extension/vendor/extension-kit is @arraypress/extension-kit 1.2.0; 1.3.0 is installed']
|
|
93
|
+
*/
|
|
94
|
+
export declare function checkVendored(dir: string, { from }?: {
|
|
95
|
+
from?: string;
|
|
96
|
+
}): Promise<string[]>;
|
package/src/build/vendor.js
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Copy
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* copy is made from the installed package on
|
|
6
|
-
*
|
|
2
|
+
* Copy browser code from npm packages into an extension folder. A packaged
|
|
3
|
+
* extension can't load files from outside itself, so an extension without a
|
|
4
|
+
* bundler loads the kit (and any other library) from a copy in
|
|
5
|
+
* `extension/vendor/`; the copy is made from the installed package on
|
|
6
|
+
* `npm install` (`postinstall`) and is never committed.
|
|
7
|
+
*
|
|
8
|
+
* Each copy is stamped with the package's name and version
|
|
9
|
+
* ({@link VENDOR_STAMP}), so {@link checkVendored} can say when a copy no
|
|
10
|
+
* longer matches what's installed: `npm install some-package` installs
|
|
11
|
+
* without running the extension's own `postinstall`, and the copy goes stale
|
|
12
|
+
* without a word.
|
|
7
13
|
*
|
|
8
14
|
* An extension with a bundler imports the package instead and copies only
|
|
9
15
|
* the stylesheet ({@link STYLESHEET}).
|
|
@@ -11,27 +17,80 @@
|
|
|
11
17
|
* @module build/vendor
|
|
12
18
|
*/
|
|
13
19
|
|
|
14
|
-
import { cp, mkdir, rm } from 'node:fs/promises';
|
|
20
|
+
import { access, cp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
|
15
21
|
import { fileURLToPath } from 'node:url';
|
|
16
22
|
import path from 'node:path';
|
|
17
23
|
|
|
18
24
|
/** The package's `src/` folder. */
|
|
19
25
|
const SOURCE = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
20
26
|
|
|
21
|
-
/**
|
|
22
|
-
|
|
27
|
+
/** The kit's package name. */
|
|
28
|
+
const KIT = '@arraypress/extension-kit';
|
|
29
|
+
|
|
30
|
+
/** What a vendored copy of the kit holds, relative to `src/`: everything an extension page or worker may load. */
|
|
31
|
+
export const RUNTIME_ENTRIES = Object.freeze(['index.js', 'index.d.ts', 'about', 'commands', 'dom', 'settings', 'sites', 'styles', 'time', 'toolbar', 'ui']);
|
|
23
32
|
|
|
24
33
|
/** The kit's stylesheet, an absolute path in the installed package. */
|
|
25
34
|
export const STYLESHEET = path.join(SOURCE, 'styles', 'chrome-ui.css');
|
|
26
35
|
|
|
36
|
+
/** The file in each vendored folder that names the package and version it was copied from (left out of the zip). */
|
|
37
|
+
export const VENDOR_STAMP = 'vendored.json';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Where a vendored copy came from.
|
|
41
|
+
*
|
|
42
|
+
* @typedef {object} VendorStamp
|
|
43
|
+
* @property {string} package The package's name.
|
|
44
|
+
* @property {string} version Its version when copied.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The folder of an installed package, found as Node finds it (the nearest
|
|
49
|
+
* `node_modules/<name>` up from `from`), read from disk so a package whose
|
|
50
|
+
* `exports` hide its `package.json` is found too.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} name - The package's name, e.g. `@duckduckgo/autoconsent`.
|
|
53
|
+
* @param {string} [from] - Where to look up from. Default the current folder.
|
|
54
|
+
* @returns {Promise<string>} The package's folder.
|
|
55
|
+
* @throws {Error} When it isn't installed.
|
|
56
|
+
*/
|
|
57
|
+
export async function packageDir(name, from = process.cwd()) {
|
|
58
|
+
for (let dir = path.resolve(from); ; dir = path.dirname(dir)) {
|
|
59
|
+
const candidate = path.join(dir, 'node_modules', name);
|
|
60
|
+
if (await access(path.join(candidate, 'package.json')).then(() => true, () => false)) return candidate;
|
|
61
|
+
if (path.dirname(dir) === dir) throw new Error(`${name} isn't installed (no node_modules/${name} up from ${from})`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A package's version, from its `package.json`.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} dir - The package's folder.
|
|
69
|
+
* @returns {Promise<string>} Its version.
|
|
70
|
+
*/
|
|
71
|
+
async function versionOf(dir) {
|
|
72
|
+
return JSON.parse(await readFile(path.join(dir, 'package.json'), 'utf8')).version;
|
|
73
|
+
}
|
|
74
|
+
|
|
27
75
|
/**
|
|
28
|
-
*
|
|
76
|
+
* Write a folder's {@link VENDOR_STAMP}.
|
|
77
|
+
*
|
|
78
|
+
* @param {string} dest - The vendored folder.
|
|
79
|
+
* @param {VendorStamp} source - Where the copy came from.
|
|
80
|
+
* @returns {Promise<void>}
|
|
81
|
+
*/
|
|
82
|
+
async function stamp(dest, source) {
|
|
83
|
+
await writeFile(path.join(dest, VENDOR_STAMP), `${JSON.stringify(source, null, 2)}\n`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Replace `dest` with a fresh copy of the kit's runtime modules and stylesheet.
|
|
29
88
|
*
|
|
30
89
|
* @param {string} dest - The folder to fill, e.g. `extension/vendor/extension-kit`. Removed first.
|
|
31
90
|
* @returns {Promise<string[]>} The entries copied, relative to `dest`.
|
|
32
91
|
* @example
|
|
33
92
|
* await vendorKit('extension/vendor/extension-kit');
|
|
34
|
-
* // extension/vendor/extension-kit/index.js, …/styles/chrome-ui.css,
|
|
93
|
+
* // extension/vendor/extension-kit/index.js, …/styles/chrome-ui.css, …, vendored.json
|
|
35
94
|
*/
|
|
36
95
|
export async function vendorKit(dest) {
|
|
37
96
|
await rm(dest, { recursive: true, force: true });
|
|
@@ -39,5 +98,69 @@ export async function vendorKit(dest) {
|
|
|
39
98
|
for (const entry of RUNTIME_ENTRIES) {
|
|
40
99
|
await cp(path.join(SOURCE, entry), path.join(dest, entry), { recursive: true });
|
|
41
100
|
}
|
|
101
|
+
await stamp(dest, { package: KIT, version: await versionOf(path.dirname(SOURCE)) });
|
|
42
102
|
return [...RUNTIME_ENTRIES];
|
|
43
103
|
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Replace `dest` with named files and folders of an installed npm package.
|
|
107
|
+
* Each path is relative to the package's folder and lands in `dest` under
|
|
108
|
+
* its own name (`dist/lib.esm.js` → `dest/lib.esm.js`); a path ending in `/`
|
|
109
|
+
* has its contents copied into `dest` instead (`src/` → `dest/index.js`, …).
|
|
110
|
+
*
|
|
111
|
+
* @param {string} name - The package, as installed (`@arraypress/clean-link`).
|
|
112
|
+
* @param {readonly string[]} files - What to copy.
|
|
113
|
+
* @param {string} dest - The folder to fill, e.g. `extension/vendor/clean-link`. Removed first.
|
|
114
|
+
* @param {{from?: string}} [options] - `from`: where to look for `node_modules` from. Default the current folder.
|
|
115
|
+
* @returns {Promise<string[]>} The files and folders written, relative to `dest`.
|
|
116
|
+
* @throws {Error} When the package isn't installed, or a path isn't in it.
|
|
117
|
+
* @example
|
|
118
|
+
* await vendorPackage('@duckduckgo/autoconsent', ['dist/autoconsent.esm.js', 'rules/compact-rules.json', 'LICENSE'], 'extension/vendor/autoconsent');
|
|
119
|
+
* // or, from postinstall: extension-kit vendor extension/vendor/clean-link @arraypress/clean-link src/
|
|
120
|
+
*/
|
|
121
|
+
export async function vendorPackage(name, files, dest, { from } = {}) {
|
|
122
|
+
const dir = await packageDir(name, from);
|
|
123
|
+
await rm(dest, { recursive: true, force: true });
|
|
124
|
+
await mkdir(dest, { recursive: true });
|
|
125
|
+
/** @type {string[]} */
|
|
126
|
+
const written = [];
|
|
127
|
+
for (const file of files) {
|
|
128
|
+
const source = path.join(dir, file);
|
|
129
|
+
if (file.endsWith('/')) {
|
|
130
|
+
await cp(source, dest, { recursive: true });
|
|
131
|
+
written.push(...await readdir(source));
|
|
132
|
+
} else {
|
|
133
|
+
await cp(source, path.join(dest, path.basename(file)), { recursive: true });
|
|
134
|
+
written.push(path.basename(file));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
await stamp(dest, { package: name, version: await versionOf(dir) });
|
|
138
|
+
return written;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Every vendored copy under `dir` (a folder with a {@link VENDOR_STAMP},
|
|
143
|
+
* searched to any depth) that no longer matches the package installed now:
|
|
144
|
+
* copied from another version, or from a package that's gone.
|
|
145
|
+
*
|
|
146
|
+
* @param {string} dir - The vendor folder, e.g. `extension/vendor`.
|
|
147
|
+
* @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 (or there are none).
|
|
149
|
+
* @example
|
|
150
|
+
* const stale = await checkVendored('extension/vendor');
|
|
151
|
+
* // ['extension/vendor/extension-kit is @arraypress/extension-kit 1.2.0; 1.3.0 is installed']
|
|
152
|
+
*/
|
|
153
|
+
export async function checkVendored(dir, { from } = {}) {
|
|
154
|
+
const entries = await readdir(dir, { recursive: true }).catch(() => /** @type {string[]} */ ([]));
|
|
155
|
+
/** @type {string[]} */
|
|
156
|
+
const stale = [];
|
|
157
|
+
for (const entry of entries.filter((file) => path.basename(file) === VENDOR_STAMP).sort()) {
|
|
158
|
+
const folder = path.join(dir, path.dirname(entry));
|
|
159
|
+
/** @type {VendorStamp} */
|
|
160
|
+
const copied = JSON.parse(await readFile(path.join(dir, entry), 'utf8'));
|
|
161
|
+
const installed = await packageDir(copied.package, from).then(versionOf, () => null);
|
|
162
|
+
if (installed === null) stale.push(`${folder} is ${copied.package} ${copied.version}, which isn't installed`);
|
|
163
|
+
else if (installed !== copied.version) stale.push(`${folder} is ${copied.package} ${copied.version}; ${installed} is installed`);
|
|
164
|
+
}
|
|
165
|
+
return stale;
|
|
166
|
+
}
|
package/src/build/zip.d.ts
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
*/
|
|
7
7
|
/**
|
|
8
8
|
* `zip -x` patterns left out by default (a `*` crosses folders): SVG sources
|
|
9
|
-
* of rendered icons, type declarations that only editors read,
|
|
9
|
+
* of rendered icons, type declarations that only editors read, the stamps
|
|
10
|
+
* of vendored copies (`vendored.json`, build/vendor.js), and Finder's
|
|
10
11
|
* `.DS_Store` files.
|
|
11
12
|
*/
|
|
12
13
|
export declare const ZIP_EXCLUDE: readonly string[];
|
|
@@ -15,7 +16,7 @@ export declare const ZIP_EXCLUDE: readonly string[];
|
|
|
15
16
|
* {@link ZIP_EXCLUDE}: for tests that compare the zip with the folder.
|
|
16
17
|
*
|
|
17
18
|
* @param {string} file - Path relative to the extension folder, forward slashes.
|
|
18
|
-
* @returns {boolean} False for `icons/**.svg`, `*.d.ts` and `.DS_Store`.
|
|
19
|
+
* @returns {boolean} False for `icons/**.svg`, `*.d.ts`, a vendored copy's `vendored.json` and `.DS_Store`.
|
|
19
20
|
*/
|
|
20
21
|
export declare function shipsFile(file: string): boolean;
|
|
21
22
|
/**
|
package/src/build/zip.js
CHANGED
|
@@ -11,20 +11,22 @@ import path from 'node:path';
|
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* `zip -x` patterns left out by default (a `*` crosses folders): SVG sources
|
|
14
|
-
* of rendered icons, type declarations that only editors read,
|
|
14
|
+
* of rendered icons, type declarations that only editors read, the stamps
|
|
15
|
+
* of vendored copies (`vendored.json`, build/vendor.js), and Finder's
|
|
15
16
|
* `.DS_Store` files.
|
|
16
17
|
*/
|
|
17
|
-
export const ZIP_EXCLUDE = Object.freeze(['icons/*.svg', '*.d.ts', '*.DS_Store']);
|
|
18
|
+
export const ZIP_EXCLUDE = Object.freeze(['icons/*.svg', '*.d.ts', '*/vendored.json', '*.DS_Store']);
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* Whether a file under an extension folder ships, by the same rules as
|
|
21
22
|
* {@link ZIP_EXCLUDE}: for tests that compare the zip with the folder.
|
|
22
23
|
*
|
|
23
24
|
* @param {string} file - Path relative to the extension folder, forward slashes.
|
|
24
|
-
* @returns {boolean} False for `icons/**.svg`, `*.d.ts` and `.DS_Store`.
|
|
25
|
+
* @returns {boolean} False for `icons/**.svg`, `*.d.ts`, a vendored copy's `vendored.json` and `.DS_Store`.
|
|
25
26
|
*/
|
|
26
27
|
export function shipsFile(file) {
|
|
27
|
-
return !/^icons\/.*\.svg$/.test(file) && !file.endsWith('.d.ts') &&
|
|
28
|
+
return !/^icons\/.*\.svg$/.test(file) && !file.endsWith('.d.ts') && !/\/vendored\.json$/.test(file)
|
|
29
|
+
&& path.posix.basename(file) !== '.DS_Store';
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
/**
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The focus and the document, seen through shadow roots: what the page
|
|
3
|
+
* components need to work inside a content script's shadow root as well as
|
|
4
|
+
* in a page of their own. A shadow root's focused element shows outside it
|
|
5
|
+
* as the host, so `document.activeElement` alone can't say which item of a
|
|
6
|
+
* menu in a shadow root has the focus.
|
|
7
|
+
*
|
|
8
|
+
* @module dom/focus
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* The element that really has the focus: the document's `activeElement`,
|
|
12
|
+
* followed into each open shadow root it's the host of.
|
|
13
|
+
*
|
|
14
|
+
* @param {Document | ShadowRoot} [root] - Where to start. Default `document`.
|
|
15
|
+
* @returns {Element | null} The focused element, or null when nothing has it.
|
|
16
|
+
* @example
|
|
17
|
+
* // A button focused in a content script's shadow root:
|
|
18
|
+
* document.activeElement; // → the shadow root's host
|
|
19
|
+
* focusedElement(); // → the button
|
|
20
|
+
*/
|
|
21
|
+
export declare function focusedElement(root?: Document | ShadowRoot): Element | null;
|
|
22
|
+
/**
|
|
23
|
+
* The document a component is made in: `root`'s, or the page's.
|
|
24
|
+
*
|
|
25
|
+
* @param {ParentNode} [root] - Where the component goes (an element or a shadow root), if given.
|
|
26
|
+
* @returns {Document} Its document, else `document`.
|
|
27
|
+
*/
|
|
28
|
+
export declare function rootDocument(root?: ParentNode): Document;
|
package/src/dom/focus.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The focus and the document, seen through shadow roots: what the page
|
|
3
|
+
* components need to work inside a content script's shadow root as well as
|
|
4
|
+
* in a page of their own. A shadow root's focused element shows outside it
|
|
5
|
+
* as the host, so `document.activeElement` alone can't say which item of a
|
|
6
|
+
* menu in a shadow root has the focus.
|
|
7
|
+
*
|
|
8
|
+
* @module dom/focus
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The element that really has the focus: the document's `activeElement`,
|
|
13
|
+
* followed into each open shadow root it's the host of.
|
|
14
|
+
*
|
|
15
|
+
* @param {Document | ShadowRoot} [root] - Where to start. Default `document`.
|
|
16
|
+
* @returns {Element | null} The focused element, or null when nothing has it.
|
|
17
|
+
* @example
|
|
18
|
+
* // A button focused in a content script's shadow root:
|
|
19
|
+
* document.activeElement; // → the shadow root's host
|
|
20
|
+
* focusedElement(); // → the button
|
|
21
|
+
*/
|
|
22
|
+
export function focusedElement(root = document) {
|
|
23
|
+
let active = root.activeElement;
|
|
24
|
+
while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement;
|
|
25
|
+
return active;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The document a component is made in: `root`'s, or the page's.
|
|
30
|
+
*
|
|
31
|
+
* @param {ParentNode} [root] - Where the component goes (an element or a shadow root), if given.
|
|
32
|
+
* @returns {Document} Its document, else `document`.
|
|
33
|
+
*/
|
|
34
|
+
export function rootDocument(root) {
|
|
35
|
+
return root ? /** @type {Node} */ (/** @type {unknown} */ (root)).ownerDocument ?? document : document;
|
|
36
|
+
}
|
package/src/dom/i18n.d.ts
CHANGED
|
@@ -4,6 +4,11 @@
|
|
|
4
4
|
* `data-i18n-<attribute>="key"` (sets that attribute, e.g.
|
|
5
5
|
* `data-i18n-placeholder`, `data-i18n-aria-label`).
|
|
6
6
|
*
|
|
7
|
+
* The extension's name lives in one message, `extShortName`; a message that
|
|
8
|
+
* names the extension takes it as a placeholder whose content is `$1`
|
|
9
|
+
* (`"About $NAME$"`), and every lookup made without substitutions fills it
|
|
10
|
+
* in, in markup and script alike.
|
|
11
|
+
*
|
|
7
12
|
* @module dom/i18n
|
|
8
13
|
*/
|
|
9
14
|
export type I18nApi = {
|
|
@@ -19,18 +24,21 @@ export type I18nApi = {
|
|
|
19
24
|
*/
|
|
20
25
|
/**
|
|
21
26
|
* A message lookup that never comes back blank: a missing key shows as
|
|
22
|
-
* itself, so a typo is visible on screen instead of an empty label.
|
|
27
|
+
* itself, so a typo is visible on screen instead of an empty label. Without
|
|
28
|
+
* substitutions, `$1` is the extension's name (`extShortName`).
|
|
23
29
|
*
|
|
24
30
|
* @param {Pick<I18nApi, 'getMessage'>} i18n - `chrome.i18n`.
|
|
25
31
|
* @returns {(key: string, substitutions?: string | string[]) => string} Looks a key up.
|
|
26
32
|
* @example
|
|
27
33
|
* const t = translator(chrome.i18n);
|
|
28
34
|
* t('optionsVersion', ['1.0.0']); // 'Version 1.0.0'
|
|
35
|
+
* t('optionsAbout'); // 'About Upright', from "About $NAME$"
|
|
29
36
|
*/
|
|
30
37
|
export declare function translator(i18n: Pick<I18nApi, 'getMessage'>): (key: string, substitutions?: string | string[]) => string;
|
|
31
38
|
/**
|
|
32
|
-
* Fill every `data-i18n*` attribute under `root
|
|
33
|
-
* set its `lang` to
|
|
39
|
+
* Fill every `data-i18n*` attribute under `root` (a message that names the
|
|
40
|
+
* extension gets its name), and, for a whole document, set its `lang` to
|
|
41
|
+
* Chrome's UI language.
|
|
34
42
|
*
|
|
35
43
|
* @param {ParentNode} root - A document or an element.
|
|
36
44
|
* @param {I18nApi} i18n - `chrome.i18n`.
|
package/src/dom/i18n.js
CHANGED
|
@@ -4,9 +4,17 @@
|
|
|
4
4
|
* `data-i18n-<attribute>="key"` (sets that attribute, e.g.
|
|
5
5
|
* `data-i18n-placeholder`, `data-i18n-aria-label`).
|
|
6
6
|
*
|
|
7
|
+
* The extension's name lives in one message, `extShortName`; a message that
|
|
8
|
+
* names the extension takes it as a placeholder whose content is `$1`
|
|
9
|
+
* (`"About $NAME$"`), and every lookup made without substitutions fills it
|
|
10
|
+
* in, in markup and script alike.
|
|
11
|
+
*
|
|
7
12
|
* @module dom/i18n
|
|
8
13
|
*/
|
|
9
14
|
|
|
15
|
+
/** The message that holds the extension's name. */
|
|
16
|
+
const NAME_MESSAGE = 'extShortName';
|
|
17
|
+
|
|
10
18
|
/**
|
|
11
19
|
* `chrome.i18n`, as far as pages need it.
|
|
12
20
|
*
|
|
@@ -17,21 +25,26 @@
|
|
|
17
25
|
|
|
18
26
|
/**
|
|
19
27
|
* A message lookup that never comes back blank: a missing key shows as
|
|
20
|
-
* itself, so a typo is visible on screen instead of an empty label.
|
|
28
|
+
* itself, so a typo is visible on screen instead of an empty label. Without
|
|
29
|
+
* substitutions, `$1` is the extension's name (`extShortName`).
|
|
21
30
|
*
|
|
22
31
|
* @param {Pick<I18nApi, 'getMessage'>} i18n - `chrome.i18n`.
|
|
23
32
|
* @returns {(key: string, substitutions?: string | string[]) => string} Looks a key up.
|
|
24
33
|
* @example
|
|
25
34
|
* const t = translator(chrome.i18n);
|
|
26
35
|
* t('optionsVersion', ['1.0.0']); // 'Version 1.0.0'
|
|
36
|
+
* t('optionsAbout'); // 'About Upright', from "About $NAME$"
|
|
27
37
|
*/
|
|
28
38
|
export function translator(i18n) {
|
|
29
|
-
|
|
39
|
+
const name = i18n.getMessage(NAME_MESSAGE);
|
|
40
|
+
const named = name ? [name] : undefined;
|
|
41
|
+
return (key, substitutions = named) => i18n.getMessage(key, substitutions) || key;
|
|
30
42
|
}
|
|
31
43
|
|
|
32
44
|
/**
|
|
33
|
-
* Fill every `data-i18n*` attribute under `root
|
|
34
|
-
* set its `lang` to
|
|
45
|
+
* Fill every `data-i18n*` attribute under `root` (a message that names the
|
|
46
|
+
* extension gets its name), and, for a whole document, set its `lang` to
|
|
47
|
+
* Chrome's UI language.
|
|
35
48
|
*
|
|
36
49
|
* @param {ParentNode} root - A document or an element.
|
|
37
50
|
* @param {I18nApi} i18n - `chrome.i18n`.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What every extension page does first: report its light or dark scheme
|
|
3
|
+
* for the toolbar glyph, fill its `data-i18n` text, and set its title.
|
|
4
|
+
* Browser only.
|
|
5
|
+
*
|
|
6
|
+
* @module dom/setup
|
|
7
|
+
*/
|
|
8
|
+
import type { I18nApi } from './i18n.js';
|
|
9
|
+
import type { SchemeApi, SchemeMedia } from '../toolbar/scheme.js';
|
|
10
|
+
export type PageSetup = {
|
|
11
|
+
/**
|
|
12
|
+
* The title's message (“optionsTitle”). Default: the title is left as it is.
|
|
13
|
+
*/
|
|
14
|
+
title?: string;
|
|
15
|
+
/**
|
|
16
|
+
* The title's placeholders. Default: the extension's name (`translator`).
|
|
17
|
+
*/
|
|
18
|
+
substitutions?: string | string[];
|
|
19
|
+
/**
|
|
20
|
+
* The page. Default `document`.
|
|
21
|
+
*/
|
|
22
|
+
doc?: Document;
|
|
23
|
+
/**
|
|
24
|
+
* The scheme to report. Default the page's `matchMedia(DARK_QUERY)`.
|
|
25
|
+
*/
|
|
26
|
+
media?: SchemeMedia;
|
|
27
|
+
};
|
|
28
|
+
/** @import { I18nApi } from './i18n.js' */
|
|
29
|
+
/** @import { SchemeApi, SchemeMedia } from '../toolbar/scheme.js' */
|
|
30
|
+
/**
|
|
31
|
+
* What {@link setUpPage} takes besides `chrome`.
|
|
32
|
+
*
|
|
33
|
+
* @typedef {object} PageSetup
|
|
34
|
+
* @property {string} [title] The title's message (“optionsTitle”). Default: the title is left as it is.
|
|
35
|
+
* @property {string | string[]} [substitutions] The title's placeholders. Default: the extension's name (`translator`).
|
|
36
|
+
* @property {Document} [doc] The page. Default `document`.
|
|
37
|
+
* @property {SchemeMedia} [media] The scheme to report. Default the page's `matchMedia(DARK_QUERY)`.
|
|
38
|
+
*/
|
|
39
|
+
/**
|
|
40
|
+
* Set up an extension page: start reporting its colour scheme (a failure is
|
|
41
|
+
* only logged: the glyph is cosmetic), localise it, and set its title.
|
|
42
|
+
*
|
|
43
|
+
* @param {SchemeApi & {i18n: I18nApi}} api - `chrome`.
|
|
44
|
+
* @param {PageSetup} [options] - The title's message, and the page.
|
|
45
|
+
* @returns {(key: string, substitutions?: string | string[]) => string} The page's message lookup
|
|
46
|
+
* (`translator(chrome.i18n)`).
|
|
47
|
+
* @example
|
|
48
|
+
* const t = setUpPage(chrome, { title: 'optionsTitle' }); // "$NAME$ settings": 'Upright settings'
|
|
49
|
+
* t('optionsGetHelp'); // "Get help with $NAME$": 'Get help with Upright'
|
|
50
|
+
* // The popup, whose title Chrome doesn't show:
|
|
51
|
+
* const t = setUpPage(chrome);
|
|
52
|
+
*/
|
|
53
|
+
export declare function setUpPage(api: SchemeApi & {
|
|
54
|
+
i18n: I18nApi;
|
|
55
|
+
}, { title, substitutions, doc, media }?: PageSetup): (key: string, substitutions?: string | string[]) => string;
|
package/src/dom/setup.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What every extension page does first: report its light or dark scheme
|
|
3
|
+
* for the toolbar glyph, fill its `data-i18n` text, and set its title.
|
|
4
|
+
* Browser only.
|
|
5
|
+
*
|
|
6
|
+
* @module dom/setup
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { localize, translator } from './i18n.js';
|
|
10
|
+
import { DARK_QUERY, watchColorScheme } from '../toolbar/scheme.js';
|
|
11
|
+
|
|
12
|
+
/** @import { I18nApi } from './i18n.js' */
|
|
13
|
+
/** @import { SchemeApi, SchemeMedia } from '../toolbar/scheme.js' */
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* What {@link setUpPage} takes besides `chrome`.
|
|
17
|
+
*
|
|
18
|
+
* @typedef {object} PageSetup
|
|
19
|
+
* @property {string} [title] The title's message (“optionsTitle”). Default: the title is left as it is.
|
|
20
|
+
* @property {string | string[]} [substitutions] The title's placeholders. Default: the extension's name (`translator`).
|
|
21
|
+
* @property {Document} [doc] The page. Default `document`.
|
|
22
|
+
* @property {SchemeMedia} [media] The scheme to report. Default the page's `matchMedia(DARK_QUERY)`.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Set up an extension page: start reporting its colour scheme (a failure is
|
|
27
|
+
* only logged: the glyph is cosmetic), localise it, and set its title.
|
|
28
|
+
*
|
|
29
|
+
* @param {SchemeApi & {i18n: I18nApi}} api - `chrome`.
|
|
30
|
+
* @param {PageSetup} [options] - The title's message, and the page.
|
|
31
|
+
* @returns {(key: string, substitutions?: string | string[]) => string} The page's message lookup
|
|
32
|
+
* (`translator(chrome.i18n)`).
|
|
33
|
+
* @example
|
|
34
|
+
* const t = setUpPage(chrome, { title: 'optionsTitle' }); // "$NAME$ settings": 'Upright settings'
|
|
35
|
+
* t('optionsGetHelp'); // "Get help with $NAME$": 'Get help with Upright'
|
|
36
|
+
* // The popup, whose title Chrome doesn't show:
|
|
37
|
+
* const t = setUpPage(chrome);
|
|
38
|
+
*/
|
|
39
|
+
export function setUpPage(api, { title, substitutions, doc = document, media } = {}) {
|
|
40
|
+
const t = translator(api.i18n);
|
|
41
|
+
// The toolbar glyph follows this page's light or dark scheme.
|
|
42
|
+
watchColorScheme(api, media ?? /** @type {Window} */ (doc.defaultView).matchMedia(DARK_QUERY))
|
|
43
|
+
.catch((error) => console.debug('Reporting the colour scheme:', error));
|
|
44
|
+
localize(doc, api.i18n);
|
|
45
|
+
if (title) doc.title = t(title, substitutions);
|
|
46
|
+
return t;
|
|
47
|
+
}
|
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
3
|
* service worker load, Chrome's page components (the ⋮ menu, the one-field
|
|
4
|
-
* dialog, the toast with Undo
|
|
4
|
+
* dialog, the toast with Undo, a settings card's site list) and per-site
|
|
5
|
+
* lists' sites included. Every function takes the `chrome`
|
|
5
6
|
* APIs it uses as arguments, so tests run the real logic against fakes.
|
|
6
7
|
*
|
|
7
8
|
* Build-time helpers are in `@arraypress/extension-kit/build`, test helpers
|
|
@@ -12,21 +13,27 @@
|
|
|
12
13
|
*/
|
|
13
14
|
export { githubAboutLinks, isPlaceholder } from './about/links.js';
|
|
14
15
|
export { setShortcuts } from './commands/shortcuts.js';
|
|
16
|
+
export { focusedElement } from './dom/focus.js';
|
|
15
17
|
export { localize, translator } from './dom/i18n.js';
|
|
16
18
|
export { byId, setAboutLinks, statusFlasher, valueRow } from './dom/page.js';
|
|
19
|
+
export { setUpPage } from './dom/setup.js';
|
|
17
20
|
export { createSettingsSaver } from './settings/saver.js';
|
|
18
21
|
export { MAX_SYNC_ITEM_BYTES } from './settings/storage.js';
|
|
19
22
|
export { createSettingsStore } from './settings/store.js';
|
|
23
|
+
export { MAX_HOST_LENGTH, hostPatterns, onSite, originPattern, siteHost, siteLabel, siteOrigin, withSite, withSiteReplaced, withoutSite, } from './sites/sites.js';
|
|
24
|
+
export { within } from './time/time.js';
|
|
20
25
|
export { POLL_MS, watchSchemeOffscreen } from './toolbar/offscreen.js';
|
|
21
26
|
export { DARK_QUERY, TOOLBAR_DARK_KEY, TOOLBAR_SCHEME, reportColorScheme, schemeSender, watchColorScheme } from './toolbar/scheme.js';
|
|
22
27
|
export { OFFSCREEN_PATH, TOOLBAR_ICON_DIR, createToolbarTheme, startToolbarTheme, toolbarIcon, watchFromOffscreen, } from './toolbar/theme.js';
|
|
23
28
|
export { MENU_EDGE, createMenu, isInside, menuPosition, moreActionsButton } from './ui/menu.js';
|
|
29
|
+
export { SITE_LIST_MESSAGES, SITE_LIST_WORDS, createSiteList } from './ui/site-list.js';
|
|
24
30
|
export { askText, checkText } from './ui/text-dialog.js';
|
|
25
31
|
export { TOAST_MS, createToast } from './ui/toast.js';
|
|
26
32
|
export type ActionMenu = import('./ui/menu.js').ActionMenu;
|
|
27
33
|
export type MenuEntry = import('./ui/menu.js').MenuEntry;
|
|
28
34
|
export type MenuItem = import('./ui/menu.js').MenuItem;
|
|
29
35
|
export type MenuPlace = import('./ui/menu.js').MenuPlace;
|
|
36
|
+
export type SiteList = import('./ui/site-list.js').SiteList;
|
|
30
37
|
export type TextRequest = import('./ui/text-dialog.js').TextRequest;
|
|
31
38
|
export type Toast = import('./ui/toast.js').Toast;
|
|
32
39
|
/**
|
|
@@ -36,6 +43,7 @@ export type Toast = import('./ui/toast.js').Toast;
|
|
|
36
43
|
* @typedef {import('./ui/menu.js').MenuEntry} MenuEntry
|
|
37
44
|
* @typedef {import('./ui/menu.js').MenuItem} MenuItem
|
|
38
45
|
* @typedef {import('./ui/menu.js').MenuPlace} MenuPlace
|
|
46
|
+
* @typedef {import('./ui/site-list.js').SiteList} SiteList
|
|
39
47
|
* @typedef {import('./ui/text-dialog.js').TextRequest} TextRequest
|
|
40
48
|
* @typedef {import('./ui/toast.js').Toast} Toast
|
|
41
49
|
*/
|