@arraypress/extension-kit 1.2.0 → 1.3.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.
@@ -1,26 +1,96 @@
1
1
  /**
2
- * Copy the kit's browser half (the runtime modules and the stylesheet) into
3
- * an extension folder. A packaged extension can't load files from outside
4
- * itself, so an extension without a bundler loads the kit from a copy; the
5
- * copy is made from the installed package on `npm install` and is never
6
- * committed, so it can't drift from the version in package.json.
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
- * Replace `dest` with a fresh copy of the runtime modules and stylesheet.
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[]>;
@@ -1,9 +1,15 @@
1
1
  /**
2
- * Copy the kit's browser half (the runtime modules and the stylesheet) into
3
- * an extension folder. A packaged extension can't load files from outside
4
- * itself, so an extension without a bundler loads the kit from a copy; the
5
- * copy is made from the installed package on `npm install` and is never
6
- * committed, so it can't drift from the version in package.json.
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
- /** What a vendored copy holds, relative to `src/`: everything an extension page or worker may load. */
22
- export const RUNTIME_ENTRIES = Object.freeze(['index.js', 'index.d.ts', 'about', 'commands', 'dom', 'settings', 'styles', 'toolbar', 'ui']);
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', '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
- * Replace `dest` with a fresh copy of the runtime modules and stylesheet.
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
+ }
@@ -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, and Finder's
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, and Finder's
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') && path.posix.basename(file) !== '.DS_Store';
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;
@@ -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
+ }
@@ -0,0 +1,54 @@
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 (the extension's name).
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 (the extension's name).
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', substitutions: [t('extShortName')] });
49
+ * // The popup, whose title Chrome doesn't show:
50
+ * const t = setUpPage(chrome);
51
+ */
52
+ export declare function setUpPage(api: SchemeApi & {
53
+ i18n: I18nApi;
54
+ }, { title, substitutions, doc, media }?: PageSetup): (key: string, substitutions?: string | string[]) => string;
@@ -0,0 +1,46 @@
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 (the extension's name).
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', substitutions: [t('extShortName')] });
35
+ * // The popup, whose title Chrome doesn't show:
36
+ * const t = setUpPage(chrome);
37
+ */
38
+ export function setUpPage(api, { title, substitutions, doc = document, media } = {}) {
39
+ const t = translator(api.i18n);
40
+ // The toolbar glyph follows this page's light or dark scheme.
41
+ watchColorScheme(api, media ?? /** @type {Window} */ (doc.defaultView).matchMedia(DARK_QUERY))
42
+ .catch((error) => console.debug('Reporting the colour scheme:', error));
43
+ localize(doc, api.i18n);
44
+ if (title) doc.title = t(title, substitutions);
45
+ return t;
46
+ }
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) included. Every function takes the `chrome`
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,26 @@
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 } from './sites/sites.js';
20
24
  export { POLL_MS, watchSchemeOffscreen } from './toolbar/offscreen.js';
21
25
  export { DARK_QUERY, TOOLBAR_DARK_KEY, TOOLBAR_SCHEME, reportColorScheme, schemeSender, watchColorScheme } from './toolbar/scheme.js';
22
26
  export { OFFSCREEN_PATH, TOOLBAR_ICON_DIR, createToolbarTheme, startToolbarTheme, toolbarIcon, watchFromOffscreen, } from './toolbar/theme.js';
23
27
  export { MENU_EDGE, createMenu, isInside, menuPosition, moreActionsButton } from './ui/menu.js';
28
+ export { createSiteList } from './ui/site-list.js';
24
29
  export { askText, checkText } from './ui/text-dialog.js';
25
30
  export { TOAST_MS, createToast } from './ui/toast.js';
26
31
  export type ActionMenu = import('./ui/menu.js').ActionMenu;
27
32
  export type MenuEntry = import('./ui/menu.js').MenuEntry;
28
33
  export type MenuItem = import('./ui/menu.js').MenuItem;
29
34
  export type MenuPlace = import('./ui/menu.js').MenuPlace;
35
+ export type SiteList = import('./ui/site-list.js').SiteList;
30
36
  export type TextRequest = import('./ui/text-dialog.js').TextRequest;
31
37
  export type Toast = import('./ui/toast.js').Toast;
32
38
  /**
@@ -36,6 +42,7 @@ export type Toast = import('./ui/toast.js').Toast;
36
42
  * @typedef {import('./ui/menu.js').MenuEntry} MenuEntry
37
43
  * @typedef {import('./ui/menu.js').MenuItem} MenuItem
38
44
  * @typedef {import('./ui/menu.js').MenuPlace} MenuPlace
45
+ * @typedef {import('./ui/site-list.js').SiteList} SiteList
39
46
  * @typedef {import('./ui/text-dialog.js').TextRequest} TextRequest
40
47
  * @typedef {import('./ui/toast.js').Toast} Toast
41
48
  */
package/src/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * The kit's browser half: what an extension's pages, offscreen document and
3
3
  * service worker load, Chrome's page components (the ⋮ menu, the one-field
4
- * dialog, the toast with Undo) included. Every function takes the `chrome`
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
@@ -13,17 +14,21 @@
13
14
 
14
15
  export { githubAboutLinks, isPlaceholder } from './about/links.js';
15
16
  export { setShortcuts } from './commands/shortcuts.js';
17
+ export { focusedElement } from './dom/focus.js';
16
18
  export { localize, translator } from './dom/i18n.js';
17
19
  export { byId, setAboutLinks, statusFlasher, valueRow } from './dom/page.js';
20
+ export { setUpPage } from './dom/setup.js';
18
21
  export { createSettingsSaver } from './settings/saver.js';
19
22
  export { MAX_SYNC_ITEM_BYTES } from './settings/storage.js';
20
23
  export { createSettingsStore } from './settings/store.js';
24
+ export { MAX_HOST_LENGTH, hostPatterns, onSite, originPattern, siteHost, siteLabel, siteOrigin } from './sites/sites.js';
21
25
  export { POLL_MS, watchSchemeOffscreen } from './toolbar/offscreen.js';
22
26
  export { DARK_QUERY, TOOLBAR_DARK_KEY, TOOLBAR_SCHEME, reportColorScheme, schemeSender, watchColorScheme } from './toolbar/scheme.js';
23
27
  export {
24
28
  OFFSCREEN_PATH, TOOLBAR_ICON_DIR, createToolbarTheme, startToolbarTheme, toolbarIcon, watchFromOffscreen,
25
29
  } from './toolbar/theme.js';
26
30
  export { MENU_EDGE, createMenu, isInside, menuPosition, moreActionsButton } from './ui/menu.js';
31
+ export { createSiteList } from './ui/site-list.js';
27
32
  export { askText, checkText } from './ui/text-dialog.js';
28
33
  export { TOAST_MS, createToast } from './ui/toast.js';
29
34
 
@@ -34,6 +39,7 @@ export { TOAST_MS, createToast } from './ui/toast.js';
34
39
  * @typedef {import('./ui/menu.js').MenuEntry} MenuEntry
35
40
  * @typedef {import('./ui/menu.js').MenuItem} MenuItem
36
41
  * @typedef {import('./ui/menu.js').MenuPlace} MenuPlace
42
+ * @typedef {import('./ui/site-list.js').SiteList} SiteList
37
43
  * @typedef {import('./ui/text-dialog.js').TextRequest} TextRequest
38
44
  * @typedef {import('./ui/toast.js').Toast} Toast
39
45
  */