@arraypress/extension-kit 0.0.0-stage → 1.1.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 +217 -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 +222 -0
- package/src/testing/e2e.js +327 -0
- package/src/testing/fake-storage.d.ts +71 -0
- package/src/testing/fake-storage.js +145 -0
- package/src/testing/index.d.ts +29 -0
- package/src/testing/index.js +24 -0
- package/src/testing/package.d.ts +39 -0
- package/src/testing/package.js +96 -0
- package/src/testing/server.d.ts +47 -0
- package/src/testing/server.js +90 -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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 David Sherlock / ArrayPress Limited
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,218 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @arraypress/extension-kit
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The pieces ArrayPress's Chrome extensions share, so each is written once:
|
|
4
|
+
|
|
5
|
+
- **`chrome-ui.css`**: one stylesheet that makes an extension page look like Chrome's own
|
|
6
|
+
(chrome://settings for settings pages, chrome://bookmarks for manager pages, the side panel).
|
|
7
|
+
- **A settings store on Chrome Sync**: one sync item per setting, per-computer settings in local,
|
|
8
|
+
debounced writes for options pages, and writes sync refuses kept on the computer and retried.
|
|
9
|
+
- **The light/dark toolbar glyph**: pages (or an offscreen document) report the colour scheme, the
|
|
10
|
+
service worker shows the matching icon.
|
|
11
|
+
- **About links**: README, Issues and PRIVACY.md in the extension's GitHub repository, plus
|
|
12
|
+
package-time warnings for placeholder links.
|
|
13
|
+
- **Build and test helpers** (Node): toolbar icon rendering, zipping for the Chrome Web Store, a
|
|
14
|
+
fake `chrome.storage`, Playwright helpers, packaging checks, and a copied-code guard.
|
|
15
|
+
|
|
16
|
+
Zero dependencies. Every runtime function takes the `chrome` APIs it uses as arguments, so tests run
|
|
17
|
+
the real logic against fakes.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install --save-dev @arraypress/extension-kit
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### An extension without a bundler
|
|
26
|
+
|
|
27
|
+
A packaged extension can't load files from outside its folder, so copy the browser half in on
|
|
28
|
+
install, and never commit the copy:
|
|
29
|
+
|
|
30
|
+
```json
|
|
31
|
+
{
|
|
32
|
+
"scripts": { "postinstall": "extension-kit vendor extension/vendor/extension-kit" }
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```gitignore
|
|
37
|
+
extension/vendor/
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Then import it relatively, and link the stylesheet first on every page:
|
|
41
|
+
|
|
42
|
+
```js
|
|
43
|
+
import { startToolbarTheme } from '../vendor/extension-kit/index.js';
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
```html
|
|
47
|
+
<link rel="stylesheet" href="../vendor/extension-kit/styles/chrome-ui.css">
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The copy is made from the installed package every `npm install`, so it can't drift from the
|
|
51
|
+
version in `package.json`. The zip helper leaves its `.d.ts` files out.
|
|
52
|
+
|
|
53
|
+
### An extension with a bundler
|
|
54
|
+
|
|
55
|
+
Import `@arraypress/extension-kit` directly and copy the stylesheet into the build from
|
|
56
|
+
`STYLESHEET` (`@arraypress/extension-kit/build`), or resolve
|
|
57
|
+
`@arraypress/extension-kit/chrome-ui.css`.
|
|
58
|
+
|
|
59
|
+
## Settings
|
|
60
|
+
|
|
61
|
+
```js
|
|
62
|
+
import { createSettingsSaver, createSettingsStore } from '../vendor/extension-kit/index.js';
|
|
63
|
+
|
|
64
|
+
export const store = createSettingsStore({
|
|
65
|
+
defaults: { menuStyle: 'single', sites: [] },
|
|
66
|
+
// Coerce anything (stored, typed, from a newer version) into valid settings.
|
|
67
|
+
normalize: (raw) => ({
|
|
68
|
+
menuStyle: raw.menuStyle === 'full' ? 'full' : 'single',
|
|
69
|
+
sites: Array.isArray(raw.sites) ? raw.sites.filter((site) => typeof site === 'string') : [],
|
|
70
|
+
}),
|
|
71
|
+
// Tied to this computer (say, they need a site permission Chrome grants per computer).
|
|
72
|
+
localKeys: ['sites'],
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const settings = await store.load(chrome.storage);
|
|
76
|
+
await store.save(chrome.storage, { menuStyle: 'full' }); // → { settings, synced }
|
|
77
|
+
chrome.storage.onChanged.addListener((changes, area) => {
|
|
78
|
+
if (store.changed(changes, area).length) rebuildMenu();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Options page: a burst of clicks is one sync write (sync allows 120 a minute).
|
|
82
|
+
const saver = createSettingsSaver(store, chrome.storage, { onSaved: showSaved });
|
|
83
|
+
saver.save({ menuStyle: 'full' });
|
|
84
|
+
addEventListener('pagehide', () => saver.flush());
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
| Store member | What it does |
|
|
88
|
+
|---|---|
|
|
89
|
+
| `load(storage)` | Every setting: sync, then held refusals, then local, normalised. |
|
|
90
|
+
| `save(storage, changes)` | Writes only the settings named, so another computer's change to a different one survives. Resolves to `{settings, synced}`. |
|
|
91
|
+
| `writeSynced(storage, values)` | One sync write; what sync refuses (rate limit, an item over 8,192 bytes) is kept in local under `unsyncedKey` and retried with the next write. |
|
|
92
|
+
| `retryUnsynced(storage)` | Try the held refusals again. |
|
|
93
|
+
| `changed(changes, area)` | The settings a `chrome.storage.onChanged` event touches. |
|
|
94
|
+
| `normalize(raw)`, `defaults`, `localKeys`, `syncedKeys`, `unsyncedKey` | The schema. |
|
|
95
|
+
|
|
96
|
+
## Toolbar glyph
|
|
97
|
+
|
|
98
|
+
```js
|
|
99
|
+
// background.js: restore the last glyph, open the offscreen watcher, handle reports.
|
|
100
|
+
startToolbarTheme(chrome);
|
|
101
|
+
|
|
102
|
+
// offscreen/offscreen.js (manifest permission "offscreen"):
|
|
103
|
+
watchSchemeOffscreen(chrome.runtime);
|
|
104
|
+
|
|
105
|
+
// Every extension page:
|
|
106
|
+
watchColorScheme(chrome).catch(() => {});
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
An extension that already runs on every page (a content script) can skip the offscreen document:
|
|
110
|
+
call `createToolbarTheme({storage, action, extensionId})` in the worker and pass its `onMessage`
|
|
111
|
+
the reports, which the content script sends with `reportColorScheme(media, stored,
|
|
112
|
+
schemeSender(chrome.runtime))`.
|
|
113
|
+
|
|
114
|
+
Render the glyphs from `icons/toolbar/glyph.svg` (and optional pixel-fitted `glyph-16.svg`):
|
|
115
|
+
|
|
116
|
+
```js
|
|
117
|
+
import { chromium } from 'playwright';
|
|
118
|
+
import { renderToolbarIcons } from '@arraypress/extension-kit/build';
|
|
119
|
+
await renderToolbarIcons({ chromium, dir: 'extension/icons/toolbar' });
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Colours are Chrome 157's toolbar icon colours, #474747 / #C7C7C7 (sources in the file).
|
|
123
|
+
|
|
124
|
+
## About links
|
|
125
|
+
|
|
126
|
+
```js
|
|
127
|
+
export const REPOSITORY = 'https://github.com/arraypress/upright';
|
|
128
|
+
export const ABOUT_LINKS = githubAboutLinks(REPOSITORY); // { help, reportIssue, privacyPolicy }
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
At the end of `npm run package`, `printReleaseChecks(ABOUT_LINKS, REPOSITORY)` warns loudly while a
|
|
132
|
+
link is a placeholder (`isPlaceholder`: example.com/.org/.net) and reminds you the repository must
|
|
133
|
+
be public before the store listing goes live.
|
|
134
|
+
|
|
135
|
+
## Build helpers (`@arraypress/extension-kit/build`)
|
|
136
|
+
|
|
137
|
+
| Export | What it does |
|
|
138
|
+
|---|---|
|
|
139
|
+
| `zipExtension(from, to, exclude?)` | Zip a folder for the Chrome Web Store, files only, less `ZIP_EXCLUDE` (icon SVG sources, `.d.ts`, `.DS_Store`). |
|
|
140
|
+
| `shipsFile(file)` | Whether a file ships, by the same rules. |
|
|
141
|
+
| `vendorKit(dest)` | Copy the browser half into an extension (the `extension-kit vendor` command). |
|
|
142
|
+
| `renderToolbarIcons({chromium, dir})` | See above. |
|
|
143
|
+
| `placeholderWarning`, `repositoryReminder`, `printReleaseChecks` | See About links. |
|
|
144
|
+
| `findDisallowedClones({runJscpd})` | Run jscpd and return clones `.jscpd-allowlist.json` doesn't cover (the `extension-kit duplication` command). |
|
|
145
|
+
|
|
146
|
+
### Copied-code guard
|
|
147
|
+
|
|
148
|
+
With `jscpd` as a devDependency and a `.jscpd.json`, `extension-kit duplication` fails on any clone
|
|
149
|
+
except those listed, each with a reason:
|
|
150
|
+
|
|
151
|
+
```json
|
|
152
|
+
{ "allow": [{ "files": ["test/e2e/a.mjs", "test/e2e/b.mjs"], "reason": "Two fixtures that must stay literal." }] }
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Test helpers (`@arraypress/extension-kit/testing`)
|
|
156
|
+
|
|
157
|
+
| Export | What it does |
|
|
158
|
+
|---|---|
|
|
159
|
+
| `fakeChromeStorage({local, sync, session, now})` | In-memory `chrome.storage`: JSON copies, `onChanged` for real changes only, sync's 8,192-byte item quota and 120 writes a minute. `settled()` waits for listeners. |
|
|
160
|
+
| `launchExtension(chromium, dir, options)` | Load an unpacked extension into a fresh profile; `{context, worker, extensionId, url(page), profile, close()}`. |
|
|
161
|
+
| `extensionProfile(chromium, dir, options)` | A profile to start the browser on more than once (a restart, a first start with a changed manifest): `{dir, launch(), remove()}`. |
|
|
162
|
+
| `checkRunner()` | Named checks that report and go on: `check(name, fn)`, `fail(name)` for an error found outside a check, `finish()`. |
|
|
163
|
+
| `serve(routes, {port})` | Serve test pages on 127.0.0.1 from a table, a `Map` or a function of the path; `{origin, port, server, close()}`. |
|
|
164
|
+
| `until(fn, what, timeout?)` | Poll until truthy. |
|
|
165
|
+
| `offscreenEmulator(cdp)` | Emulate light/dark inside the offscreen document. |
|
|
166
|
+
| `recordIconCalls(worker)` | Record `chrome.action.setIcon` calls Chrome accepted. |
|
|
167
|
+
| `filesUnder`, `zipEntries`, `missingReferences`, `manifestReferences` | For a packaging test: the zip is the folder, and every path the extension names resolves. |
|
|
168
|
+
|
|
169
|
+
## Stylesheet classes
|
|
170
|
+
|
|
171
|
+
| Piece | Markup |
|
|
172
|
+
|---|---|
|
|
173
|
+
| Page column (680px, 96%) | `<main class="page">` |
|
|
174
|
+
| Toolbar (56px) | `<header class="toolbar">` with `.toolbar-start` (img + `h1`), `.toolbar-center`, `.toolbar-end` |
|
|
175
|
+
| Search pill | `<label class="search-field"><svg class="icon">…</svg><input type="search"></label>` |
|
|
176
|
+
| Section title | `<h2 class="section-title">` above its card |
|
|
177
|
+
| Card (one per section) | `<section class="card">` |
|
|
178
|
+
| Row (48px) | `.row` with `<span class="label">Text<span class="secondary">Help</span></span>` and a control |
|
|
179
|
+
| Two-line row (64px) | `.row.two-line` |
|
|
180
|
+
| Paragraph row | `<p class="row text"><span class="label">…</span></p>` |
|
|
181
|
+
| Value at the end | `<span class="row-value">⌥⇧W</span>` |
|
|
182
|
+
| Heading inside a card | `.card-heading` |
|
|
183
|
+
| Link row | `<button class="link-row">` (›) or `.link-row.external` (↗), label as in `.row` |
|
|
184
|
+
| No separator above | `.first` / `.continuation` (the first row of a card gets none by itself) |
|
|
185
|
+
| List of rows | `<ul class="list">` of `li.row` |
|
|
186
|
+
| Toggle (cr-toggle) | `<input type="checkbox" class="switch" role="switch">` |
|
|
187
|
+
| Select (md-select) | plain `<select>` |
|
|
188
|
+
| Text field (cr-input) | plain `<input>`, `<textarea>`; `.field` > `label` + input for a labelled one |
|
|
189
|
+
| Buttons (cr-button) | plain `<button>`; `.action` filled; `.link` as a link; `.icon-button` 32px round |
|
|
190
|
+
| Settings menu | `.nav` > `ul.nav-list` > `a.nav-item[aria-current="page"]`, `.nav-separator` |
|
|
191
|
+
| Manager layout | `.manager`/`.layout`, `.sidebar` with `.tree` > `.tree-item`, `.main` with `.list-card` |
|
|
192
|
+
| Menu (cr-action-menu) | `<dialog class="menu">` of `button[role="menuitem"]`, `hr` |
|
|
193
|
+
| Dialog (cr-dialog) | `<dialog class="dialog"><form method="dialog">` `.dialog-title`, `.dialog-body`, `.dialog-buttons` |
|
|
194
|
+
| Toast (cr-toast) | `<div class="toast">text <button>Undo</button></div>` |
|
|
195
|
+
| Drawer (cr-drawer) | `<dialog class="drawer">` with `.drawer-head` and a `.nav` |
|
|
196
|
+
| Side panel page | `<body class="side-panel">`: the panel's colours, a column 100vh high |
|
|
197
|
+
| Panel tabs (cr-tabs) | `<div class="tabs" role="tablist">` of `button.tab[role="tab"][aria-selected]` |
|
|
198
|
+
| Panel body | `.sp-scroller`; `.search-field` inside a panel is the white pill |
|
|
199
|
+
| Panel card | `<section class="sp-card">` with `.sp-heading` (`h2` + 24px `.icon-button`s, `.expand[aria-expanded]`) |
|
|
200
|
+
| Panel row (cr-url-list-item) | `ul.sp-list` > `li.sp-item`: `button.sp-item-main`, `.sp-icon`, `.sp-text` > `.sp-title` + `.sp-description`, `.sp-suffix` |
|
|
201
|
+
| Panel empty state | `.sp-empty` with `.icon`, `h2`, `p` |
|
|
202
|
+
| Panel footer + pill | `<footer class="sp-footer"><button class="floating">` |
|
|
203
|
+
| Utilities | `.secondary`, `.error`, `.hint`, `.mono`, `.visually-hidden`, `.icon`, `.sprite` |
|
|
204
|
+
|
|
205
|
+
Tokens (`--text`, `--text-secondary`, `--primary`, `--separator`, `--card`, `--page-bg`, `--hover`,
|
|
206
|
+
`--selected-bg`, the side panel's `--sp-*`, …) switch with `prefers-color-scheme`; use them for
|
|
207
|
+
anything a page adds. Values come from Chromium's source, checked against chrome://settings and
|
|
208
|
+
chrome://bookmarks in Chromium 156 / Chrome 157; the sources are listed at the top of the file.
|
|
209
|
+
|
|
210
|
+
## Development
|
|
211
|
+
|
|
212
|
+
`npm test` runs ESLint, a strict TypeScript check of the JSDoc (`checkJs`), the tests, and the
|
|
213
|
+
copied-code guard. The `.d.ts` files are generated from the JSDoc: `npm run types` writes them, and
|
|
214
|
+
the type check fails while one is stale (it regenerates them into `.types/` and compares).
|
|
215
|
+
|
|
216
|
+
## License
|
|
217
|
+
|
|
218
|
+
MIT © 2026 David Sherlock / ArrayPress Limited
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The kit's command line, run from an extension's npm scripts:
|
|
4
|
+
*
|
|
5
|
+
* extension-kit vendor <dir> copy the browser half into <dir> (npm install runs it)
|
|
6
|
+
* extension-kit duplication fail on copied code jscpd finds, less .jscpd-allowlist.json
|
|
7
|
+
*
|
|
8
|
+
* @module bin/extension-kit
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createRequire } from 'node:module';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { findDisallowedClones } from '../src/build/duplication.js';
|
|
14
|
+
import { vendorKit } from '../src/build/vendor.js';
|
|
15
|
+
|
|
16
|
+
const [command, arg] = process.argv.slice(2);
|
|
17
|
+
|
|
18
|
+
if (command === 'vendor' && arg) {
|
|
19
|
+
await vendorKit(path.resolve(arg));
|
|
20
|
+
console.log(`extension-kit: copied into ${arg}`);
|
|
21
|
+
} else if (command === 'duplication') {
|
|
22
|
+
// jscpd is the extension's devDependency, so resolve it from there.
|
|
23
|
+
const { runJscpd } = createRequire(path.join(process.cwd(), 'package.json'))('jscpd');
|
|
24
|
+
const clones = await findDisallowedClones({ runJscpd });
|
|
25
|
+
for (const clone of clones) console.error(`Copied code (${clone.lines} lines): ${clone.where}`);
|
|
26
|
+
if (clones.length) {
|
|
27
|
+
console.error(`\n${clones.length} clone(s). Move the shared code into one function, or list the pair with a reason in .jscpd-allowlist.json.`);
|
|
28
|
+
process.exitCode = 1;
|
|
29
|
+
} else {
|
|
30
|
+
console.log('No copied code.');
|
|
31
|
+
}
|
|
32
|
+
} else {
|
|
33
|
+
console.error('Usage: extension-kit vendor <dir> | extension-kit duplication');
|
|
34
|
+
process.exitCode = 2;
|
|
35
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,69 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arraypress/extension-kit",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Shared pieces for Chrome extensions: Chrome's own page styles, a Chrome Sync settings store, the light/dark toolbar glyph, About links, and build and test helpers.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"types": "src/index.d.ts",
|
|
8
|
+
"sideEffects": false,
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./src/index.d.ts",
|
|
12
|
+
"import": "./src/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./build": {
|
|
15
|
+
"types": "./src/build/index.d.ts",
|
|
16
|
+
"import": "./src/build/index.js"
|
|
17
|
+
},
|
|
18
|
+
"./testing": {
|
|
19
|
+
"types": "./src/testing/index.d.ts",
|
|
20
|
+
"import": "./src/testing/index.js"
|
|
21
|
+
},
|
|
22
|
+
"./chrome-ui.css": "./src/styles/chrome-ui.css"
|
|
23
|
+
},
|
|
24
|
+
"bin": {
|
|
25
|
+
"extension-kit": "bin/extension-kit.js"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"src",
|
|
29
|
+
"bin"
|
|
30
|
+
],
|
|
31
|
+
"scripts": {
|
|
32
|
+
"test": "npm run lint && npm run typecheck && node --test tests/*.test.js && npm run duplication",
|
|
33
|
+
"lint": "eslint .",
|
|
34
|
+
"typecheck": "tsc -p . && tsc -p tsconfig.types.json && diff -r -x '*.js' -x styles .types src && rm -rf .types",
|
|
35
|
+
"types": "tsc -p tsconfig.types.json && cp -R .types/. src/ && rm -rf .types",
|
|
36
|
+
"duplication": "node bin/extension-kit.js duplication"
|
|
37
|
+
},
|
|
38
|
+
"keywords": [
|
|
39
|
+
"chrome-extension",
|
|
40
|
+
"manifest-v3",
|
|
41
|
+
"chrome-storage",
|
|
42
|
+
"chrome-sync",
|
|
43
|
+
"settings",
|
|
44
|
+
"dark-mode",
|
|
45
|
+
"toolbar-icon",
|
|
46
|
+
"css"
|
|
47
|
+
],
|
|
48
|
+
"author": "David Sherlock",
|
|
49
|
+
"license": "MIT",
|
|
50
|
+
"repository": {
|
|
51
|
+
"type": "git",
|
|
52
|
+
"url": "git+https://github.com/arraypress/extension-kit.git"
|
|
53
|
+
},
|
|
54
|
+
"engines": {
|
|
55
|
+
"node": ">=20"
|
|
56
|
+
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"@eslint/js": "^10.0.1",
|
|
59
|
+
"@types/chrome": "^0.3.7",
|
|
60
|
+
"@types/jsdom": "^30.0.0",
|
|
61
|
+
"@types/node": "^26.6.6",
|
|
62
|
+
"eslint": "^10.14.0",
|
|
63
|
+
"globals": "^17.14.0",
|
|
64
|
+
"jscpd": "^5.4.3",
|
|
65
|
+
"jsdom": "^31.0.0",
|
|
66
|
+
"playwright": "^1.64.0",
|
|
67
|
+
"typescript": "^7.0.2"
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The links on an extension's About card ("Get help", "Report an issue",
|
|
3
|
+
* "Privacy policy"), kept in the extension's GitHub repository: the README,
|
|
4
|
+
* Issues and PRIVACY.md. The repository must be public before the extension
|
|
5
|
+
* is published, or these links show GitHub's 404; the build helpers'
|
|
6
|
+
* `repositoryReminder` says so at package time.
|
|
7
|
+
*
|
|
8
|
+
* @module about/links
|
|
9
|
+
*/
|
|
10
|
+
export type AboutLinks = {
|
|
11
|
+
/**
|
|
12
|
+
* "Get help with …": the README.
|
|
13
|
+
*/
|
|
14
|
+
help: string;
|
|
15
|
+
/**
|
|
16
|
+
* "Report an issue": GitHub Issues.
|
|
17
|
+
*/
|
|
18
|
+
reportIssue: string;
|
|
19
|
+
/**
|
|
20
|
+
* "Privacy policy": PRIVACY.md on the main branch.
|
|
21
|
+
*/
|
|
22
|
+
privacyPolicy: string;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {object} AboutLinks
|
|
26
|
+
* @property {string} help "Get help with …": the README.
|
|
27
|
+
* @property {string} reportIssue "Report an issue": GitHub Issues.
|
|
28
|
+
* @property {string} privacyPolicy "Privacy policy": PRIVACY.md on the main branch.
|
|
29
|
+
*/
|
|
30
|
+
/**
|
|
31
|
+
* The About card's links for a GitHub repository.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} repository - The repository's address, no trailing slash.
|
|
34
|
+
* @returns {Readonly<AboutLinks>} The three links.
|
|
35
|
+
* @example
|
|
36
|
+
* githubAboutLinks('https://github.com/arraypress/upright').reportIssue;
|
|
37
|
+
* // 'https://github.com/arraypress/upright/issues'
|
|
38
|
+
*/
|
|
39
|
+
export declare function githubAboutLinks(repository: string): Readonly<AboutLinks>;
|
|
40
|
+
/**
|
|
41
|
+
* Whether a link is a placeholder: its host is one reserved for examples
|
|
42
|
+
* (RFC 2606), or a subdomain of one. A `mailto:` link or a real domain isn't.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} url - An absolute URL.
|
|
45
|
+
* @returns {boolean} True for example.com, example.org, example.net and their subdomains.
|
|
46
|
+
* @throws {TypeError} When `url` isn't an absolute URL.
|
|
47
|
+
* @example
|
|
48
|
+
* isPlaceholder('https://www.example.org/help'); // true
|
|
49
|
+
* isPlaceholder('https://notexample.com/'); // false
|
|
50
|
+
*/
|
|
51
|
+
export declare function isPlaceholder(url: string): boolean;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The links on an extension's About card ("Get help", "Report an issue",
|
|
3
|
+
* "Privacy policy"), kept in the extension's GitHub repository: the README,
|
|
4
|
+
* Issues and PRIVACY.md. The repository must be public before the extension
|
|
5
|
+
* is published, or these links show GitHub's 404; the build helpers'
|
|
6
|
+
* `repositoryReminder` says so at package time.
|
|
7
|
+
*
|
|
8
|
+
* @module about/links
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {object} AboutLinks
|
|
13
|
+
* @property {string} help "Get help with …": the README.
|
|
14
|
+
* @property {string} reportIssue "Report an issue": GitHub Issues.
|
|
15
|
+
* @property {string} privacyPolicy "Privacy policy": PRIVACY.md on the main branch.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The About card's links for a GitHub repository.
|
|
20
|
+
*
|
|
21
|
+
* @param {string} repository - The repository's address, no trailing slash.
|
|
22
|
+
* @returns {Readonly<AboutLinks>} The three links.
|
|
23
|
+
* @example
|
|
24
|
+
* githubAboutLinks('https://github.com/arraypress/upright').reportIssue;
|
|
25
|
+
* // 'https://github.com/arraypress/upright/issues'
|
|
26
|
+
*/
|
|
27
|
+
export function githubAboutLinks(repository) {
|
|
28
|
+
return Object.freeze({
|
|
29
|
+
help: `${repository}#readme`,
|
|
30
|
+
reportIssue: `${repository}/issues`,
|
|
31
|
+
privacyPolicy: `${repository}/blob/main/PRIVACY.md`,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Whether a link is a placeholder: its host is one reserved for examples
|
|
37
|
+
* (RFC 2606), or a subdomain of one. A `mailto:` link or a real domain isn't.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} url - An absolute URL.
|
|
40
|
+
* @returns {boolean} True for example.com, example.org, example.net and their subdomains.
|
|
41
|
+
* @throws {TypeError} When `url` isn't an absolute URL.
|
|
42
|
+
* @example
|
|
43
|
+
* isPlaceholder('https://www.example.org/help'); // true
|
|
44
|
+
* isPlaceholder('https://notexample.com/'); // false
|
|
45
|
+
*/
|
|
46
|
+
export function isPlaceholder(url) {
|
|
47
|
+
return /(^|\.)example\.(com|org|net)$/.test(new URL(url).hostname);
|
|
48
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
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
|
+
export type Clone = {
|
|
15
|
+
/**
|
|
16
|
+
* First file, relative to the scan's folder.
|
|
17
|
+
*/
|
|
18
|
+
first: string;
|
|
19
|
+
/**
|
|
20
|
+
* Second file (may equal `first`).
|
|
21
|
+
*/
|
|
22
|
+
second: string;
|
|
23
|
+
/**
|
|
24
|
+
* How many lines it spans.
|
|
25
|
+
*/
|
|
26
|
+
lines: number;
|
|
27
|
+
/**
|
|
28
|
+
* "a.js:10-20 ~ b.js:30-40", for messages.
|
|
29
|
+
*/
|
|
30
|
+
where: string;
|
|
31
|
+
};
|
|
32
|
+
export type AllowEntry = {
|
|
33
|
+
/**
|
|
34
|
+
* Files whose clones of each other are allowed.
|
|
35
|
+
*/
|
|
36
|
+
files: string[];
|
|
37
|
+
/**
|
|
38
|
+
* Why the copy stays.
|
|
39
|
+
*/
|
|
40
|
+
reason: string;
|
|
41
|
+
};
|
|
42
|
+
export type RunJscpd = (args: readonly string[], options: {
|
|
43
|
+
cwd: string;
|
|
44
|
+
encoding: 'utf8';
|
|
45
|
+
}) => {
|
|
46
|
+
status: number | null;
|
|
47
|
+
stderr: string;
|
|
48
|
+
error?: Error;
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* One clone from jscpd's JSON report, reduced to where it is.
|
|
52
|
+
*
|
|
53
|
+
* @typedef {object} Clone
|
|
54
|
+
* @property {string} first First file, relative to the scan's folder.
|
|
55
|
+
* @property {string} second Second file (may equal `first`).
|
|
56
|
+
* @property {number} lines How many lines it spans.
|
|
57
|
+
* @property {string} where "a.js:10-20 ~ b.js:30-40", for messages.
|
|
58
|
+
*/
|
|
59
|
+
/**
|
|
60
|
+
* One deliberate exception.
|
|
61
|
+
*
|
|
62
|
+
* @typedef {object} AllowEntry
|
|
63
|
+
* @property {string[]} files Files whose clones of each other are allowed.
|
|
64
|
+
* @property {string} reason Why the copy stays.
|
|
65
|
+
*/
|
|
66
|
+
/**
|
|
67
|
+
* jscpd's `runJscpd` (from the `jscpd` package): spawns its binary.
|
|
68
|
+
*
|
|
69
|
+
* @typedef {(args: readonly string[], options: {cwd: string, encoding: 'utf8'}) => {status: number | null, stderr: string, error?: Error}} RunJscpd
|
|
70
|
+
*/
|
|
71
|
+
/**
|
|
72
|
+
* The clones jscpd's JSON report lists.
|
|
73
|
+
*
|
|
74
|
+
* @param {{duplicates?: {firstFile: {name: string, start: number, end: number}, secondFile: {name: string, start: number, end: number}, lines: number}[]}} report
|
|
75
|
+
* The parsed `jscpd-report.json`.
|
|
76
|
+
* @returns {Clone[]} One entry per clone.
|
|
77
|
+
*/
|
|
78
|
+
export declare function clonesFromReport(report: {
|
|
79
|
+
duplicates?: {
|
|
80
|
+
firstFile: {
|
|
81
|
+
name: string;
|
|
82
|
+
start: number;
|
|
83
|
+
end: number;
|
|
84
|
+
};
|
|
85
|
+
secondFile: {
|
|
86
|
+
name: string;
|
|
87
|
+
start: number;
|
|
88
|
+
end: number;
|
|
89
|
+
};
|
|
90
|
+
lines: number;
|
|
91
|
+
}[];
|
|
92
|
+
}): Clone[];
|
|
93
|
+
/**
|
|
94
|
+
* The clones no allowlist entry covers.
|
|
95
|
+
*
|
|
96
|
+
* @param {readonly Clone[]} clones - What jscpd found.
|
|
97
|
+
* @param {readonly AllowEntry[]} allow - The deliberate exceptions.
|
|
98
|
+
* @returns {Clone[]} The clones to fail on.
|
|
99
|
+
* @example
|
|
100
|
+
* disallowedClones(clones, [{ files: ['a.js', 'b.js'], reason: 'fixtures' }]);
|
|
101
|
+
*/
|
|
102
|
+
export declare function disallowedClones(clones: readonly Clone[], allow: readonly AllowEntry[]): Clone[];
|
|
103
|
+
/**
|
|
104
|
+
* Run jscpd in `cwd` (it reads `.jscpd.json` there) and report clones the
|
|
105
|
+
* allowlist doesn't cover.
|
|
106
|
+
*
|
|
107
|
+
* @param {object} options - How to run.
|
|
108
|
+
* @param {RunJscpd} options.runJscpd - From the `jscpd` package.
|
|
109
|
+
* @param {string} [options.cwd] - The repository. Default the current folder.
|
|
110
|
+
* @returns {Promise<Clone[]>} The disallowed clones; empty means clean.
|
|
111
|
+
* @throws {Error} When jscpd can't run.
|
|
112
|
+
*/
|
|
113
|
+
export declare function findDisallowedClones({ runJscpd, cwd }: {
|
|
114
|
+
runJscpd: RunJscpd;
|
|
115
|
+
cwd?: string;
|
|
116
|
+
}): Promise<Clone[]>;
|