@arraypress/extension-kit 0.0.0-stage → 1.0.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/LICENSE +21 -0
- package/README.md +214 -2
- package/bin/extension-kit.js +35 -0
- package/package.json +67 -4
- package/src/about/links.d.ts +51 -0
- package/src/about/links.js +48 -0
- package/src/build/duplication.d.ts +116 -0
- package/src/build/duplication.js +119 -0
- package/src/build/index.d.ts +11 -0
- package/src/build/index.js +12 -0
- package/src/build/release-checks.d.ts +36 -0
- package/src/build/release-checks.js +61 -0
- package/src/build/toolbar-icons.d.ts +96 -0
- package/src/build/toolbar-icons.js +101 -0
- package/src/build/vendor.d.ts +26 -0
- package/src/build/vendor.js +43 -0
- package/src/build/zip.d.ts +31 -0
- package/src/build/zip.js +47 -0
- package/src/commands/shortcuts.d.ts +39 -0
- package/src/commands/shortcuts.js +31 -0
- package/src/dom/i18n.d.ts +39 -0
- package/src/dom/i18n.js +49 -0
- package/src/dom/page.d.ts +59 -0
- package/src/dom/page.js +93 -0
- package/src/index.d.ts +21 -0
- package/src/index.js +24 -0
- package/src/settings/saver.d.ts +76 -0
- package/src/settings/saver.js +98 -0
- package/src/settings/storage.d.ts +91 -0
- package/src/settings/storage.js +76 -0
- package/src/settings/store.d.ts +138 -0
- package/src/settings/store.js +173 -0
- package/src/styles/chrome-ui.css +1094 -0
- package/src/testing/e2e.d.ts +165 -0
- package/src/testing/e2e.js +272 -0
- package/src/testing/fake-storage.d.ts +71 -0
- package/src/testing/fake-storage.js +145 -0
- package/src/testing/index.d.ts +9 -0
- package/src/testing/index.js +10 -0
- package/src/testing/package.d.ts +39 -0
- package/src/testing/package.js +96 -0
- package/src/toolbar/offscreen.d.ts +30 -0
- package/src/toolbar/offscreen.js +37 -0
- package/src/toolbar/scheme.d.ts +81 -0
- package/src/toolbar/scheme.js +87 -0
- package/src/toolbar/theme.d.ts +187 -0
- package/src/toolbar/theme.js +176 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A guard against copied code: run jscpd over a repository with its
|
|
3
|
+
* `.jscpd.json` and fail on any clone, except the deliberate ones listed,
|
|
4
|
+
* each with its reason, in `.jscpd-allowlist.json`:
|
|
5
|
+
*
|
|
6
|
+
* { "allow": [{ "files": ["test/a.js", "test/b.js"], "reason": "…" }] }
|
|
7
|
+
*
|
|
8
|
+
* A clone is allowed when both of its files are in one entry. jscpd is the
|
|
9
|
+
* caller's (a devDependency), passed in as its `runJscpd`, so this package
|
|
10
|
+
* needs none.
|
|
11
|
+
*
|
|
12
|
+
* @module build/duplication
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
16
|
+
import { tmpdir } from 'node:os';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* One clone from jscpd's JSON report, reduced to where it is.
|
|
21
|
+
*
|
|
22
|
+
* @typedef {object} Clone
|
|
23
|
+
* @property {string} first First file, relative to the scan's folder.
|
|
24
|
+
* @property {string} second Second file (may equal `first`).
|
|
25
|
+
* @property {number} lines How many lines it spans.
|
|
26
|
+
* @property {string} where "a.js:10-20 ~ b.js:30-40", for messages.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* One deliberate exception.
|
|
31
|
+
*
|
|
32
|
+
* @typedef {object} AllowEntry
|
|
33
|
+
* @property {string[]} files Files whose clones of each other are allowed.
|
|
34
|
+
* @property {string} reason Why the copy stays.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* jscpd's `runJscpd` (from the `jscpd` package): spawns its binary.
|
|
39
|
+
*
|
|
40
|
+
* @typedef {(args: readonly string[], options: {cwd: string, encoding: 'utf8'}) => {status: number | null, stderr: string, error?: Error}} RunJscpd
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The clones jscpd's JSON report lists.
|
|
45
|
+
*
|
|
46
|
+
* @param {{duplicates?: {firstFile: {name: string, start: number, end: number}, secondFile: {name: string, start: number, end: number}, lines: number}[]}} report
|
|
47
|
+
* The parsed `jscpd-report.json`.
|
|
48
|
+
* @returns {Clone[]} One entry per clone.
|
|
49
|
+
*/
|
|
50
|
+
export function clonesFromReport(report) {
|
|
51
|
+
return (report.duplicates ?? []).map(({ firstFile: a, secondFile: b, lines }) => ({
|
|
52
|
+
first: a.name,
|
|
53
|
+
second: b.name,
|
|
54
|
+
lines,
|
|
55
|
+
where: `${a.name}:${a.start}-${a.end} ~ ${b.name}:${b.start}-${b.end}`,
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The clones no allowlist entry covers.
|
|
61
|
+
*
|
|
62
|
+
* @param {readonly Clone[]} clones - What jscpd found.
|
|
63
|
+
* @param {readonly AllowEntry[]} allow - The deliberate exceptions.
|
|
64
|
+
* @returns {Clone[]} The clones to fail on.
|
|
65
|
+
* @example
|
|
66
|
+
* disallowedClones(clones, [{ files: ['a.js', 'b.js'], reason: 'fixtures' }]);
|
|
67
|
+
*/
|
|
68
|
+
export function disallowedClones(clones, allow) {
|
|
69
|
+
return clones.filter(({ first, second }) => !allow.some(({ files }) => files.includes(first) && files.includes(second)));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Read a JSON file, or `fallback` when it doesn't exist.
|
|
74
|
+
*
|
|
75
|
+
* @template T
|
|
76
|
+
* @param {string} file - The file.
|
|
77
|
+
* @param {T} fallback - What a missing file reads as.
|
|
78
|
+
* @returns {Promise<T>} The parsed JSON.
|
|
79
|
+
*/
|
|
80
|
+
async function readJson(file, fallback) {
|
|
81
|
+
try {
|
|
82
|
+
return JSON.parse(await readFile(file, 'utf8'));
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (/** @type {NodeJS.ErrnoException} */ (error).code === 'ENOENT') return fallback;
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Run jscpd in `cwd` (it reads `.jscpd.json` there) and report clones the
|
|
91
|
+
* allowlist doesn't cover.
|
|
92
|
+
*
|
|
93
|
+
* @param {object} options - How to run.
|
|
94
|
+
* @param {RunJscpd} options.runJscpd - From the `jscpd` package.
|
|
95
|
+
* @param {string} [options.cwd] - The repository. Default the current folder.
|
|
96
|
+
* @returns {Promise<Clone[]>} The disallowed clones; empty means clean.
|
|
97
|
+
* @throws {Error} When jscpd can't run.
|
|
98
|
+
*/
|
|
99
|
+
export async function findDisallowedClones({ runJscpd, cwd = process.cwd() }) {
|
|
100
|
+
const out = await mkdtemp(path.join(tmpdir(), 'jscpd-'));
|
|
101
|
+
try {
|
|
102
|
+
// Absolute paths, made relative to the repository below: jscpd otherwise names each file
|
|
103
|
+
// relative to the scanned folder it's in, so test/a.js and scripts/a.js would look the same.
|
|
104
|
+
const run = runJscpd(['--reporters', 'json', '--output', out, '--silent', '--exit-code', '0', '--absolute'], { cwd, encoding: 'utf8' });
|
|
105
|
+
if (run.error || run.status !== 0) throw run.error ?? new Error(`jscpd failed: ${run.stderr}`);
|
|
106
|
+
/** @type {Parameters<typeof clonesFromReport>[0]} */
|
|
107
|
+
const report = await readJson(path.join(out, 'jscpd-report.json'), {});
|
|
108
|
+
for (const { firstFile, secondFile } of report.duplicates ?? []) {
|
|
109
|
+
firstFile.name = path.relative(cwd, firstFile.name);
|
|
110
|
+
secondFile.name = path.relative(cwd, secondFile.name);
|
|
111
|
+
}
|
|
112
|
+
/** @type {{allow?: AllowEntry[]}} */
|
|
113
|
+
const allowlist = await readJson(path.join(cwd, '.jscpd-allowlist.json'), {});
|
|
114
|
+
const allow = allowlist.allow ?? [];
|
|
115
|
+
return disallowedClones(clonesFromReport(report), allow);
|
|
116
|
+
} finally {
|
|
117
|
+
await rm(out, { recursive: true, force: true });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The kit's Node half, for an extension's build, package and maintenance
|
|
3
|
+
* scripts: never loaded by the extension itself.
|
|
4
|
+
*
|
|
5
|
+
* @module build
|
|
6
|
+
*/
|
|
7
|
+
export { clonesFromReport, disallowedClones, findDisallowedClones } from './duplication.js';
|
|
8
|
+
export { placeholderWarning, printReleaseChecks, repositoryReminder } from './release-checks.js';
|
|
9
|
+
export { TOOLBAR_COLORS, TOOLBAR_ICON_COLORS, TOOLBAR_ICON_SIZES, renderToolbarIcons, toolbarIconFile } from './toolbar-icons.js';
|
|
10
|
+
export { RUNTIME_ENTRIES, STYLESHEET, vendorKit } from './vendor.js';
|
|
11
|
+
export { ZIP_EXCLUDE, shipsFile, zipExtension } from './zip.js';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The kit's Node half, for an extension's build, package and maintenance
|
|
3
|
+
* scripts: never loaded by the extension itself.
|
|
4
|
+
*
|
|
5
|
+
* @module build
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export { clonesFromReport, disallowedClones, findDisallowedClones } from './duplication.js';
|
|
9
|
+
export { placeholderWarning, printReleaseChecks, repositoryReminder } from './release-checks.js';
|
|
10
|
+
export { TOOLBAR_COLORS, TOOLBAR_ICON_COLORS, TOOLBAR_ICON_SIZES, renderToolbarIcons, toolbarIconFile } from './toolbar-icons.js';
|
|
11
|
+
export { RUNTIME_ENTRIES, STYLESHEET, vendorKit } from './vendor.js';
|
|
12
|
+
export { ZIP_EXCLUDE, shipsFile, zipExtension } from './zip.js';
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-time reminders about the About card's links (about/links.js): a
|
|
3
|
+
* loud warning while any is a placeholder, so a build with example.com links
|
|
4
|
+
* can't ship by accident, and a reminder that GitHub links need a public
|
|
5
|
+
* repository. Both only print: the zip is still fine for testing.
|
|
6
|
+
*
|
|
7
|
+
* @module build/release-checks
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* The warning to print while any link is a placeholder.
|
|
11
|
+
*
|
|
12
|
+
* @param {Record<string, string>} links - The About links by name.
|
|
13
|
+
* @returns {string | null} The warning, naming each placeholder, or null when every link is real.
|
|
14
|
+
* @example
|
|
15
|
+
* placeholderWarning({ help: 'https://example.com/help' });
|
|
16
|
+
* // '!!!…\n!! WARNING: the About card still has placeholder links …\n!! help: https://example.com/help\n…'
|
|
17
|
+
*/
|
|
18
|
+
export declare function placeholderWarning(links: Record<string, string>): string | null;
|
|
19
|
+
/**
|
|
20
|
+
* The reminder to print when the links point into a repository: it has to be
|
|
21
|
+
* public before the store listing goes live.
|
|
22
|
+
*
|
|
23
|
+
* @param {Record<string, string>} links - The About links by name.
|
|
24
|
+
* @param {string} repository - The repository's address.
|
|
25
|
+
* @returns {string | null} The reminder, or null when no link is in the repository.
|
|
26
|
+
*/
|
|
27
|
+
export declare function repositoryReminder(links: Record<string, string>, repository: string): string | null;
|
|
28
|
+
/**
|
|
29
|
+
* Print both, the warning on stderr: run last in `npm run package`, so it's
|
|
30
|
+
* the thing on screen.
|
|
31
|
+
*
|
|
32
|
+
* @param {Record<string, string>} links - The About links by name.
|
|
33
|
+
* @param {string} repository - The repository's address.
|
|
34
|
+
* @returns {void}
|
|
35
|
+
*/
|
|
36
|
+
export declare function printReleaseChecks(links: Record<string, string>, repository: string): void;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-time reminders about the About card's links (about/links.js): a
|
|
3
|
+
* loud warning while any is a placeholder, so a build with example.com links
|
|
4
|
+
* can't ship by accident, and a reminder that GitHub links need a public
|
|
5
|
+
* repository. Both only print: the zip is still fine for testing.
|
|
6
|
+
*
|
|
7
|
+
* @module build/release-checks
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { isPlaceholder } from '../about/links.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The warning to print while any link is a placeholder.
|
|
14
|
+
*
|
|
15
|
+
* @param {Record<string, string>} links - The About links by name.
|
|
16
|
+
* @returns {string | null} The warning, naming each placeholder, or null when every link is real.
|
|
17
|
+
* @example
|
|
18
|
+
* placeholderWarning({ help: 'https://example.com/help' });
|
|
19
|
+
* // '!!!…\n!! WARNING: the About card still has placeholder links …\n!! help: https://example.com/help\n…'
|
|
20
|
+
*/
|
|
21
|
+
export function placeholderWarning(links) {
|
|
22
|
+
const left = Object.entries(links).filter(([, url]) => isPlaceholder(url));
|
|
23
|
+
if (!left.length) return null;
|
|
24
|
+
const bar = '!'.repeat(78);
|
|
25
|
+
return [
|
|
26
|
+
bar,
|
|
27
|
+
'!! WARNING: the About card still has placeholder links (about-links.js):',
|
|
28
|
+
...left.map(([name, url]) => `!! ${name}: ${url}`),
|
|
29
|
+
'!! Don’t submit this zip to the Chrome Web Store. Set the real URLs and rerun `npm run package`.',
|
|
30
|
+
bar,
|
|
31
|
+
].join('\n');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The reminder to print when the links point into a repository: it has to be
|
|
36
|
+
* public before the store listing goes live.
|
|
37
|
+
*
|
|
38
|
+
* @param {Record<string, string>} links - The About links by name.
|
|
39
|
+
* @param {string} repository - The repository's address.
|
|
40
|
+
* @returns {string | null} The reminder, or null when no link is in the repository.
|
|
41
|
+
*/
|
|
42
|
+
export function repositoryReminder(links, repository) {
|
|
43
|
+
return Object.values(links).some((url) => url.startsWith(repository))
|
|
44
|
+
? `Reminder: the About links point at ${repository}. Make the repository public before submitting to the Chrome Web Store.`
|
|
45
|
+
: null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Print both, the warning on stderr: run last in `npm run package`, so it's
|
|
50
|
+
* the thing on screen.
|
|
51
|
+
*
|
|
52
|
+
* @param {Record<string, string>} links - The About links by name.
|
|
53
|
+
* @param {string} repository - The repository's address.
|
|
54
|
+
* @returns {void}
|
|
55
|
+
*/
|
|
56
|
+
export function printReleaseChecks(links, repository) {
|
|
57
|
+
const warning = placeholderWarning(links);
|
|
58
|
+
if (warning) console.warn(`\n${warning}\n`);
|
|
59
|
+
const reminder = repositoryReminder(links, repository);
|
|
60
|
+
if (reminder) console.log(`\n${reminder}\n`);
|
|
61
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Toolbar glyphs: render an extension's monochrome toolbar icon in Chrome's
|
|
3
|
+
* own toolbar-icon colours, light and dark, at 16, 32 and 48px, for
|
|
4
|
+
* toolbar/theme.js to switch between.
|
|
5
|
+
*
|
|
6
|
+
* A folder holds the sources: `glyph.svg` (16×16 viewBox, drawn in
|
|
7
|
+
* `currentColor`) and, optionally, `glyph-16.svg`, the same mark hand-fitted
|
|
8
|
+
* to whole pixels for the 16px file (a 1.5px stroke smears at 1x). Out come
|
|
9
|
+
* `light-16.png`, `dark-16.png`, … beside them. The caller passes its own
|
|
10
|
+
* Playwright, so this package needs none.
|
|
11
|
+
*
|
|
12
|
+
* @module build/toolbar-icons
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Chrome's toolbar button icon colour, default theme, Chrome 157 (157.0.8103.39):
|
|
16
|
+
*
|
|
17
|
+
* chrome/browser/ui/color/material_chrome_color_mixer.cc:531-532
|
|
18
|
+
* kColorToolbarButtonIcon = kColorToolbarButtonIconDefault = kColorSysOnSurfaceSecondary
|
|
19
|
+
* ui/color/sys_color_mixer.cc:219-220 (the baseline palette, no user colour)
|
|
20
|
+
* kColorSysOnSurfaceSecondary = dark ? kColorRefNeutral80 : kColorRefNeutral30
|
|
21
|
+
* ui/color/ref_color_mixer.cc:150, 155 (default; the :107/:112 values sit behind
|
|
22
|
+
* kChromeDarkNeutrals26, off by default in ui/base/ui_base_features.cc:529)
|
|
23
|
+
* kColorRefNeutral30 = #474747, kColorRefNeutral80 = #C7C7C7
|
|
24
|
+
*
|
|
25
|
+
* Apple's Passwords extension uses #35363A, which is the pre-2023 *dark toolbar*
|
|
26
|
+
* colour (chrome_color_mixer.cc:805, kColorToolbar dark); the old light icon was
|
|
27
|
+
* kGoogleGrey700 #5F6368 (chrome_color_mixer.cc:826-827). Both are darker or bluer
|
|
28
|
+
* than the Refresh icons beside them, so we use Chrome's current values.
|
|
29
|
+
*/
|
|
30
|
+
export declare const TOOLBAR_ICON_COLORS: Readonly<{
|
|
31
|
+
light: "#474747";
|
|
32
|
+
dark: "#C7C7C7";
|
|
33
|
+
}>;
|
|
34
|
+
/**
|
|
35
|
+
* The toolbar behind them, for previews: kColorToolbar = kColorSysBase
|
|
36
|
+
* (material_chrome_color_mixer.cc:527) = dark ? kColorRefNeutral25 : kColorRefNeutral100
|
|
37
|
+
* (sys_color_mixer.cc:246) = #3C3C3C / #FFFFFF (ref_color_mixer.cc:149, 164).
|
|
38
|
+
*/
|
|
39
|
+
export declare const TOOLBAR_COLORS: Readonly<{
|
|
40
|
+
light: "#FFFFFF";
|
|
41
|
+
dark: "#3C3C3C";
|
|
42
|
+
}>;
|
|
43
|
+
/** 16 and 32 are what Chrome draws at 1x and 2x; 48 covers 3x. */
|
|
44
|
+
export declare const TOOLBAR_ICON_SIZES: readonly number[];
|
|
45
|
+
/**
|
|
46
|
+
* The file name of one rendered icon.
|
|
47
|
+
*
|
|
48
|
+
* @param {'light' | 'dark'} scheme - Which scheme's colour.
|
|
49
|
+
* @param {number} size - Pixels.
|
|
50
|
+
* @returns {string} For example `light-16.png`.
|
|
51
|
+
*/
|
|
52
|
+
export declare function toolbarIconFile(scheme: 'light' | 'dark', size: number): string;
|
|
53
|
+
export type BrowserLauncher = {
|
|
54
|
+
launch: () => Promise<{
|
|
55
|
+
newPage(options: {
|
|
56
|
+
viewport: {
|
|
57
|
+
width: number;
|
|
58
|
+
height: number;
|
|
59
|
+
};
|
|
60
|
+
deviceScaleFactor: number;
|
|
61
|
+
}): Promise<{
|
|
62
|
+
setContent(html: string): Promise<void>;
|
|
63
|
+
screenshot(options: {
|
|
64
|
+
omitBackground: boolean;
|
|
65
|
+
}): Promise<Uint8Array>;
|
|
66
|
+
close(): Promise<void>;
|
|
67
|
+
}>;
|
|
68
|
+
close(): Promise<void>;
|
|
69
|
+
}>;
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* The parts of a Playwright `BrowserType` (`chromium`) this uses.
|
|
73
|
+
*
|
|
74
|
+
* @typedef {object} BrowserLauncher
|
|
75
|
+
* @property {() => Promise<{newPage(options: {viewport: {width: number, height: number}, deviceScaleFactor: number}): Promise<{
|
|
76
|
+
* setContent(html: string): Promise<void>,
|
|
77
|
+
* screenshot(options: {omitBackground: boolean}): Promise<Uint8Array>,
|
|
78
|
+
* close(): Promise<void>}>, close(): Promise<void>}>} launch
|
|
79
|
+
*/
|
|
80
|
+
/**
|
|
81
|
+
* Render every scheme and size from `dir`'s sources into `dir`.
|
|
82
|
+
*
|
|
83
|
+
* @param {object} options - What to render with and where.
|
|
84
|
+
* @param {BrowserLauncher} options.chromium - Playwright's `chromium`.
|
|
85
|
+
* @param {string} options.dir - The folder with `glyph.svg` (and maybe `glyph-16.svg`).
|
|
86
|
+
* @param {readonly number[]} [options.sizes] - Default {@link TOOLBAR_ICON_SIZES}.
|
|
87
|
+
* @returns {Promise<string[]>} The files written.
|
|
88
|
+
* @example
|
|
89
|
+
* import { chromium } from 'playwright';
|
|
90
|
+
* await renderToolbarIcons({ chromium, dir: 'extension/icons/toolbar' });
|
|
91
|
+
*/
|
|
92
|
+
export declare function renderToolbarIcons({ chromium, dir, sizes }: {
|
|
93
|
+
chromium: BrowserLauncher;
|
|
94
|
+
dir: string;
|
|
95
|
+
sizes?: readonly number[];
|
|
96
|
+
}): Promise<string[]>;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Toolbar glyphs: render an extension's monochrome toolbar icon in Chrome's
|
|
3
|
+
* own toolbar-icon colours, light and dark, at 16, 32 and 48px, for
|
|
4
|
+
* toolbar/theme.js to switch between.
|
|
5
|
+
*
|
|
6
|
+
* A folder holds the sources: `glyph.svg` (16×16 viewBox, drawn in
|
|
7
|
+
* `currentColor`) and, optionally, `glyph-16.svg`, the same mark hand-fitted
|
|
8
|
+
* to whole pixels for the 16px file (a 1.5px stroke smears at 1x). Out come
|
|
9
|
+
* `light-16.png`, `dark-16.png`, … beside them. The caller passes its own
|
|
10
|
+
* Playwright, so this package needs none.
|
|
11
|
+
*
|
|
12
|
+
* @module build/toolbar-icons
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Chrome's toolbar button icon colour, default theme, Chrome 157 (157.0.8103.39):
|
|
20
|
+
*
|
|
21
|
+
* chrome/browser/ui/color/material_chrome_color_mixer.cc:531-532
|
|
22
|
+
* kColorToolbarButtonIcon = kColorToolbarButtonIconDefault = kColorSysOnSurfaceSecondary
|
|
23
|
+
* ui/color/sys_color_mixer.cc:219-220 (the baseline palette, no user colour)
|
|
24
|
+
* kColorSysOnSurfaceSecondary = dark ? kColorRefNeutral80 : kColorRefNeutral30
|
|
25
|
+
* ui/color/ref_color_mixer.cc:150, 155 (default; the :107/:112 values sit behind
|
|
26
|
+
* kChromeDarkNeutrals26, off by default in ui/base/ui_base_features.cc:529)
|
|
27
|
+
* kColorRefNeutral30 = #474747, kColorRefNeutral80 = #C7C7C7
|
|
28
|
+
*
|
|
29
|
+
* Apple's Passwords extension uses #35363A, which is the pre-2023 *dark toolbar*
|
|
30
|
+
* colour (chrome_color_mixer.cc:805, kColorToolbar dark); the old light icon was
|
|
31
|
+
* kGoogleGrey700 #5F6368 (chrome_color_mixer.cc:826-827). Both are darker or bluer
|
|
32
|
+
* than the Refresh icons beside them, so we use Chrome's current values.
|
|
33
|
+
*/
|
|
34
|
+
export const TOOLBAR_ICON_COLORS = Object.freeze({ light: '#474747', dark: '#C7C7C7' });
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The toolbar behind them, for previews: kColorToolbar = kColorSysBase
|
|
38
|
+
* (material_chrome_color_mixer.cc:527) = dark ? kColorRefNeutral25 : kColorRefNeutral100
|
|
39
|
+
* (sys_color_mixer.cc:246) = #3C3C3C / #FFFFFF (ref_color_mixer.cc:149, 164).
|
|
40
|
+
*/
|
|
41
|
+
export const TOOLBAR_COLORS = Object.freeze({ light: '#FFFFFF', dark: '#3C3C3C' });
|
|
42
|
+
|
|
43
|
+
/** 16 and 32 are what Chrome draws at 1x and 2x; 48 covers 3x. */
|
|
44
|
+
export const TOOLBAR_ICON_SIZES = Object.freeze([16, 32, 48]);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The file name of one rendered icon.
|
|
48
|
+
*
|
|
49
|
+
* @param {'light' | 'dark'} scheme - Which scheme's colour.
|
|
50
|
+
* @param {number} size - Pixels.
|
|
51
|
+
* @returns {string} For example `light-16.png`.
|
|
52
|
+
*/
|
|
53
|
+
export function toolbarIconFile(scheme, size) {
|
|
54
|
+
return `${scheme}-${size}.png`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The parts of a Playwright `BrowserType` (`chromium`) this uses.
|
|
59
|
+
*
|
|
60
|
+
* @typedef {object} BrowserLauncher
|
|
61
|
+
* @property {() => Promise<{newPage(options: {viewport: {width: number, height: number}, deviceScaleFactor: number}): Promise<{
|
|
62
|
+
* setContent(html: string): Promise<void>,
|
|
63
|
+
* screenshot(options: {omitBackground: boolean}): Promise<Uint8Array>,
|
|
64
|
+
* close(): Promise<void>}>, close(): Promise<void>}>} launch
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Render every scheme and size from `dir`'s sources into `dir`.
|
|
69
|
+
*
|
|
70
|
+
* @param {object} options - What to render with and where.
|
|
71
|
+
* @param {BrowserLauncher} options.chromium - Playwright's `chromium`.
|
|
72
|
+
* @param {string} options.dir - The folder with `glyph.svg` (and maybe `glyph-16.svg`).
|
|
73
|
+
* @param {readonly number[]} [options.sizes] - Default {@link TOOLBAR_ICON_SIZES}.
|
|
74
|
+
* @returns {Promise<string[]>} The files written.
|
|
75
|
+
* @example
|
|
76
|
+
* import { chromium } from 'playwright';
|
|
77
|
+
* await renderToolbarIcons({ chromium, dir: 'extension/icons/toolbar' });
|
|
78
|
+
*/
|
|
79
|
+
export async function renderToolbarIcons({ chromium, dir, sizes = TOOLBAR_ICON_SIZES }) {
|
|
80
|
+
const master = await readFile(path.join(dir, 'glyph.svg'), 'utf8');
|
|
81
|
+
const small = await readFile(path.join(dir, 'glyph-16.svg'), 'utf8').catch(() => master);
|
|
82
|
+
const browser = await chromium.launch();
|
|
83
|
+
/** @type {string[]} */
|
|
84
|
+
const written = [];
|
|
85
|
+
try {
|
|
86
|
+
for (const [scheme, color] of /** @type {['light' | 'dark', string][]} */ (Object.entries(TOOLBAR_ICON_COLORS))) {
|
|
87
|
+
for (const size of sizes) {
|
|
88
|
+
const page = await browser.newPage({ viewport: { width: size, height: size }, deviceScaleFactor: 1 });
|
|
89
|
+
const svg = size === 16 ? small : master;
|
|
90
|
+
await page.setContent(`<style>*{margin:0}svg{display:block;width:${size}px;height:${size}px;color:${color}}</style>${svg}`);
|
|
91
|
+
const file = path.join(dir, toolbarIconFile(scheme, size));
|
|
92
|
+
await writeFile(file, await page.screenshot({ omitBackground: true }));
|
|
93
|
+
written.push(file);
|
|
94
|
+
await page.close();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
} finally {
|
|
98
|
+
await browser.close();
|
|
99
|
+
}
|
|
100
|
+
return written;
|
|
101
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
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.
|
|
7
|
+
*
|
|
8
|
+
* An extension with a bundler imports the package instead and copies only
|
|
9
|
+
* the stylesheet ({@link STYLESHEET}).
|
|
10
|
+
*
|
|
11
|
+
* @module build/vendor
|
|
12
|
+
*/
|
|
13
|
+
/** What a vendored copy holds, relative to `src/`: everything an extension page or worker may load. */
|
|
14
|
+
export declare const RUNTIME_ENTRIES: readonly string[];
|
|
15
|
+
/** The kit's stylesheet, an absolute path in the installed package. */
|
|
16
|
+
export declare const STYLESHEET: string;
|
|
17
|
+
/**
|
|
18
|
+
* Replace `dest` with a fresh copy of the runtime modules and stylesheet.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} dest - The folder to fill, e.g. `extension/vendor/extension-kit`. Removed first.
|
|
21
|
+
* @returns {Promise<string[]>} The entries copied, relative to `dest`.
|
|
22
|
+
* @example
|
|
23
|
+
* await vendorKit('extension/vendor/extension-kit');
|
|
24
|
+
* // extension/vendor/extension-kit/index.js, …/styles/chrome-ui.css, …
|
|
25
|
+
*/
|
|
26
|
+
export declare function vendorKit(dest: string): Promise<string[]>;
|
|
@@ -0,0 +1,43 @@
|
|
|
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.
|
|
7
|
+
*
|
|
8
|
+
* An extension with a bundler imports the package instead and copies only
|
|
9
|
+
* the stylesheet ({@link STYLESHEET}).
|
|
10
|
+
*
|
|
11
|
+
* @module build/vendor
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { cp, mkdir, rm } from 'node:fs/promises';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
|
|
18
|
+
/** The package's `src/` folder. */
|
|
19
|
+
const SOURCE = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
20
|
+
|
|
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']);
|
|
23
|
+
|
|
24
|
+
/** The kit's stylesheet, an absolute path in the installed package. */
|
|
25
|
+
export const STYLESHEET = path.join(SOURCE, 'styles', 'chrome-ui.css');
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Replace `dest` with a fresh copy of the runtime modules and stylesheet.
|
|
29
|
+
*
|
|
30
|
+
* @param {string} dest - The folder to fill, e.g. `extension/vendor/extension-kit`. Removed first.
|
|
31
|
+
* @returns {Promise<string[]>} The entries copied, relative to `dest`.
|
|
32
|
+
* @example
|
|
33
|
+
* await vendorKit('extension/vendor/extension-kit');
|
|
34
|
+
* // extension/vendor/extension-kit/index.js, …/styles/chrome-ui.css, …
|
|
35
|
+
*/
|
|
36
|
+
export async function vendorKit(dest) {
|
|
37
|
+
await rm(dest, { recursive: true, force: true });
|
|
38
|
+
await mkdir(dest, { recursive: true });
|
|
39
|
+
for (const entry of RUNTIME_ENTRIES) {
|
|
40
|
+
await cp(path.join(SOURCE, entry), path.join(dest, entry), { recursive: true });
|
|
41
|
+
}
|
|
42
|
+
return [...RUNTIME_ENTRIES];
|
|
43
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zip an extension folder for the Chrome Web Store: the folder's contents
|
|
3
|
+
* with `manifest.json` at the zip's root, less source-only files.
|
|
4
|
+
*
|
|
5
|
+
* @module build/zip
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
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
|
|
10
|
+
* `.DS_Store` files.
|
|
11
|
+
*/
|
|
12
|
+
export declare const ZIP_EXCLUDE: readonly string[];
|
|
13
|
+
/**
|
|
14
|
+
* Whether a file under an extension folder ships, by the same rules as
|
|
15
|
+
* {@link ZIP_EXCLUDE}: for tests that compare the zip with the folder.
|
|
16
|
+
*
|
|
17
|
+
* @param {string} file - Path relative to the extension folder, forward slashes.
|
|
18
|
+
* @returns {boolean} False for `icons/**.svg`, `*.d.ts` and `.DS_Store`.
|
|
19
|
+
*/
|
|
20
|
+
export declare function shipsFile(file: string): boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Zip everything under `from` into `to`, paths relative to `from`. An older
|
|
23
|
+
* zip at `to` is replaced. Needs the `zip` command (macOS and Linux have it).
|
|
24
|
+
*
|
|
25
|
+
* @param {string} from - The extension folder.
|
|
26
|
+
* @param {string} to - The zip to write; its folder is made if missing.
|
|
27
|
+
* @param {readonly string[]} [exclude] - `zip -x` patterns. Default {@link ZIP_EXCLUDE}.
|
|
28
|
+
* @returns {Promise<string>} `to`.
|
|
29
|
+
* @throws {Error} When `zip` fails.
|
|
30
|
+
*/
|
|
31
|
+
export declare function zipExtension(from: string, to: string, exclude?: readonly string[]): Promise<string>;
|
package/src/build/zip.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zip an extension folder for the Chrome Web Store: the folder's contents
|
|
3
|
+
* with `manifest.json` at the zip's root, less source-only files.
|
|
4
|
+
*
|
|
5
|
+
* @module build/zip
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { execFileSync } from 'node:child_process';
|
|
9
|
+
import { mkdir, rm } from 'node:fs/promises';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
|
|
12
|
+
/**
|
|
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
|
|
15
|
+
* `.DS_Store` files.
|
|
16
|
+
*/
|
|
17
|
+
export const ZIP_EXCLUDE = Object.freeze(['icons/*.svg', '*.d.ts', '*.DS_Store']);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Whether a file under an extension folder ships, by the same rules as
|
|
21
|
+
* {@link ZIP_EXCLUDE}: for tests that compare the zip with the folder.
|
|
22
|
+
*
|
|
23
|
+
* @param {string} file - Path relative to the extension folder, forward slashes.
|
|
24
|
+
* @returns {boolean} False for `icons/**.svg`, `*.d.ts` and `.DS_Store`.
|
|
25
|
+
*/
|
|
26
|
+
export function shipsFile(file) {
|
|
27
|
+
return !/^icons\/.*\.svg$/.test(file) && !file.endsWith('.d.ts') && path.posix.basename(file) !== '.DS_Store';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Zip everything under `from` into `to`, paths relative to `from`. An older
|
|
32
|
+
* zip at `to` is replaced. Needs the `zip` command (macOS and Linux have it).
|
|
33
|
+
*
|
|
34
|
+
* @param {string} from - The extension folder.
|
|
35
|
+
* @param {string} to - The zip to write; its folder is made if missing.
|
|
36
|
+
* @param {readonly string[]} [exclude] - `zip -x` patterns. Default {@link ZIP_EXCLUDE}.
|
|
37
|
+
* @returns {Promise<string>} `to`.
|
|
38
|
+
* @throws {Error} When `zip` fails.
|
|
39
|
+
*/
|
|
40
|
+
export async function zipExtension(from, to, exclude = ZIP_EXCLUDE) {
|
|
41
|
+
await mkdir(path.dirname(to), { recursive: true });
|
|
42
|
+
await rm(to, { force: true });
|
|
43
|
+
// -X leaves out macOS extended attributes (the store rejects __MACOSX
|
|
44
|
+
// entries); -D leaves out folder entries, so the zip lists files only.
|
|
45
|
+
execFileSync('zip', ['-r', '-X', '-D', '-q', to, '.', '-x', ...exclude], { cwd: from, stdio: 'inherit' });
|
|
46
|
+
return to;
|
|
47
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The keyboard shortcuts an options page lists: only the ones that are set,
|
|
3
|
+
* in the extension's own order (Chrome's `commands.getAll()` order is the
|
|
4
|
+
* manifest's object order, which isn't the menu's). Users set and change them
|
|
5
|
+
* on chrome://extensions/shortcuts, which the page links to.
|
|
6
|
+
*
|
|
7
|
+
* @module commands/shortcuts
|
|
8
|
+
*/
|
|
9
|
+
export type ShortcutEntry = {
|
|
10
|
+
/**
|
|
11
|
+
* The command's name in the manifest.
|
|
12
|
+
*/
|
|
13
|
+
id: string;
|
|
14
|
+
/**
|
|
15
|
+
* What the page calls it.
|
|
16
|
+
*/
|
|
17
|
+
title: string;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {object} ShortcutEntry
|
|
21
|
+
* @property {string} id The command's name in the manifest.
|
|
22
|
+
* @property {string} title What the page calls it.
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* The shortcuts that are set, in `entries`' order.
|
|
26
|
+
*
|
|
27
|
+
* @param {readonly ShortcutEntry[]} entries - The commands to list, in order.
|
|
28
|
+
* @param {readonly {name?: string, shortcut?: string}[]} commands - `chrome.commands.getAll()`.
|
|
29
|
+
* @returns {(ShortcutEntry & {shortcut: string})[]} Each entry with its shortcut; unset ones left out.
|
|
30
|
+
* @example
|
|
31
|
+
* setShortcuts([{ id: 'reset', title: 'Reset' }], await chrome.commands.getAll());
|
|
32
|
+
* // [{ id: 'reset', title: 'Reset', shortcut: '⌥⇧0' }]
|
|
33
|
+
*/
|
|
34
|
+
export declare function setShortcuts(entries: readonly ShortcutEntry[], commands: readonly {
|
|
35
|
+
name?: string;
|
|
36
|
+
shortcut?: string;
|
|
37
|
+
}[]): (ShortcutEntry & {
|
|
38
|
+
shortcut: string;
|
|
39
|
+
})[];
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The keyboard shortcuts an options page lists: only the ones that are set,
|
|
3
|
+
* in the extension's own order (Chrome's `commands.getAll()` order is the
|
|
4
|
+
* manifest's object order, which isn't the menu's). Users set and change them
|
|
5
|
+
* on chrome://extensions/shortcuts, which the page links to.
|
|
6
|
+
*
|
|
7
|
+
* @module commands/shortcuts
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {object} ShortcutEntry
|
|
12
|
+
* @property {string} id The command's name in the manifest.
|
|
13
|
+
* @property {string} title What the page calls it.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The shortcuts that are set, in `entries`' order.
|
|
18
|
+
*
|
|
19
|
+
* @param {readonly ShortcutEntry[]} entries - The commands to list, in order.
|
|
20
|
+
* @param {readonly {name?: string, shortcut?: string}[]} commands - `chrome.commands.getAll()`.
|
|
21
|
+
* @returns {(ShortcutEntry & {shortcut: string})[]} Each entry with its shortcut; unset ones left out.
|
|
22
|
+
* @example
|
|
23
|
+
* setShortcuts([{ id: 'reset', title: 'Reset' }], await chrome.commands.getAll());
|
|
24
|
+
* // [{ id: 'reset', title: 'Reset', shortcut: '⌥⇧0' }]
|
|
25
|
+
*/
|
|
26
|
+
export function setShortcuts(entries, commands) {
|
|
27
|
+
return entries.flatMap((entry) => {
|
|
28
|
+
const shortcut = commands.find((command) => command.name === entry.id)?.shortcut;
|
|
29
|
+
return shortcut ? [{ ...entry, shortcut }] : [];
|
|
30
|
+
});
|
|
31
|
+
}
|