├── .editorconfig
├── .gitattributes
├── .github
└── workflows
│ ├── ci.yml
│ └── esm-lint.yml
├── .gitignore
├── .npmrc
├── file.ts
├── globals.d.ts
├── index.ts
├── license
├── package.json
├── readme.md
├── test
├── _env.js
└── index.js
└── tsconfig.json
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | indent_style = tab
5 | end_of_line = lf
6 | charset = utf-8
7 | trim_trailing_whitespace = true
8 | insert_final_newline = true
9 |
10 | [{package.json,*.yml}]
11 | indent_style = space
12 | indent_size = 2
13 |
14 | [*.md]
15 | trim_trailing_whitespace = false
16 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | * text=auto
2 | vendor/ linguist-vendored
3 | test/* linguist-detectable=false
4 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | env: {}
2 |
3 | # FILE GENERATED WITH: npx ghat fregante/ghatemplates/node
4 | # SOURCE: https://github.com/fregante/ghatemplates
5 |
6 | name: CI
7 |
8 | on:
9 | - pull_request
10 | - push
11 |
12 | jobs:
13 | Lint:
14 | runs-on: ubuntu-latest
15 | steps:
16 | - uses: actions/checkout@v4
17 | - uses: actions/setup-node@v4
18 | with:
19 | node-version-file: package.json
20 | - name: install
21 | run: npm ci || npm install
22 | - name: XO
23 | run: npx xo
24 |
25 | Test:
26 | runs-on: ubuntu-latest
27 | steps:
28 | - uses: actions/checkout@v4
29 | - uses: actions/setup-node@v4
30 | with:
31 | node-version-file: package.json
32 | - name: install
33 | run: npm ci || npm install
34 | - name: build
35 | run: npm run build --if-present
36 | - name: AVA
37 | run: npx ava
38 |
39 | Build:
40 | runs-on: ubuntu-latest
41 | steps:
42 | - uses: actions/checkout@v4
43 | - uses: actions/setup-node@v4
44 | with:
45 | node-version-file: package.json
46 | - name: install
47 | run: npm ci || npm install
48 | - name: build
49 | run: npm run build
50 |
--------------------------------------------------------------------------------
/.github/workflows/esm-lint.yml:
--------------------------------------------------------------------------------
1 | env:
2 | IMPORT_STATEMENT: export {default as OptionsSync} from "webext-options-sync"
3 |
4 | # FILE GENERATED WITH: npx ghat fregante/ghatemplates/esm-lint
5 | # SOURCE: https://github.com/fregante/ghatemplates
6 | # OPTIONS: {"exclude":["jobs.Rollup"]}
7 |
8 | name: ESM
9 | on:
10 | pull_request:
11 | branches:
12 | - '*'
13 | push:
14 | branches:
15 | - master
16 | - main
17 | jobs:
18 | Pack:
19 | runs-on: ubuntu-latest
20 | steps:
21 | - uses: actions/checkout@v4
22 | - run: npm install
23 | - run: npm run build --if-present
24 | - run: npm pack --dry-run
25 | - run: npm pack | tail -1 | xargs -n1 tar -xzf
26 | - uses: actions/upload-artifact@v4
27 | with:
28 | path: package
29 | Publint:
30 | runs-on: ubuntu-latest
31 | needs: Pack
32 | steps:
33 | - uses: actions/download-artifact@v4
34 | - run: npx publint ./artifact
35 | Webpack:
36 | runs-on: ubuntu-latest
37 | needs: Pack
38 | steps:
39 | - uses: actions/download-artifact@v4
40 | - run: npm install --omit=dev ./artifact
41 | - run: echo "$IMPORT_STATEMENT" > index.js
42 | - run: webpack --entry ./index.js
43 | - run: cat dist/main.js
44 | Parcel:
45 | runs-on: ubuntu-latest
46 | needs: Pack
47 | steps:
48 | - uses: actions/download-artifact@v4
49 | - run: npm install --omit=dev ./artifact
50 | - run: echo "$IMPORT_STATEMENT" > index.js
51 | - run: >
52 | echo '{"@parcel/resolver-default": {"packageExports": true}}' >
53 | package.json
54 | - run: npx parcel@2 build index.js
55 | - run: cat dist/index.js
56 | Vite:
57 | runs-on: ubuntu-latest
58 | needs: Pack
59 | steps:
60 | - uses: actions/download-artifact@v4
61 | - run: npm install --omit=dev ./artifact
62 | - run: echo '' > index.html
63 | - run: npx vite build
64 | - run: cat dist/assets/*
65 | esbuild:
66 | runs-on: ubuntu-latest
67 | needs: Pack
68 | steps:
69 | - uses: actions/download-artifact@v4
70 | - run: echo '{}' > package.json
71 | - run: echo "$IMPORT_STATEMENT" > index.js
72 | - run: npm install --omit=dev ./artifact
73 | - run: npx esbuild --bundle index.js
74 | TypeScript:
75 | runs-on: ubuntu-latest
76 | needs: Pack
77 | steps:
78 | - uses: actions/download-artifact@v4
79 | - run: echo '{"type":"module"}' > package.json
80 | - run: npm install --omit=dev ./artifact @sindresorhus/tsconfig
81 | - run: echo "$IMPORT_STATEMENT" > index.ts
82 | - run: >
83 | echo '{"extends":"@sindresorhus/tsconfig","files":["index.ts"]}' >
84 | tsconfig.json
85 | - run: npx --package typescript -- tsc
86 | - run: cat distribution/index.js
87 | Node:
88 | runs-on: ubuntu-latest
89 | needs: Pack
90 | steps:
91 | - uses: actions/download-artifact@v4
92 | - uses: actions/setup-node@v4
93 | with:
94 | node-version-file: artifact/package.json
95 | - run: echo "$IMPORT_STATEMENT" > index.mjs
96 | - run: npm install --omit=dev ./artifact
97 | - run: node index.mjs
98 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .DS_Store
3 | Desktop.ini
4 | ._*
5 | Thumbs.db
6 | *.tmp
7 | *.bak
8 | *.log
9 | logs
10 | *.map
11 | index.js
12 | index.d.ts
13 | file.js
14 | file.d.ts
15 | !*/index.js
16 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | package-lock=false
2 |
--------------------------------------------------------------------------------
/file.ts:
--------------------------------------------------------------------------------
1 | import {stringToBase64} from 'uint8array-extras';
2 |
3 | const filePickerOptions: FilePickerOptions = {
4 | types: [
5 | {
6 | accept: {
7 | // eslint-disable-next-line @typescript-eslint/naming-convention
8 | 'application/json': '.json',
9 | },
10 | },
11 | ],
12 | };
13 |
14 | const isModern = typeof showOpenFilePicker === 'function';
15 |
16 | async function loadFileOld(): Promise {
17 | const input = document.createElement('input');
18 | input.type = 'file';
19 | input.accept = '.json';
20 | const eventPromise = new Promise(resolve => {
21 | input.addEventListener('change', resolve, {once: true});
22 | });
23 |
24 | input.click();
25 | const event = await eventPromise;
26 | const file = (event.target as HTMLInputElement).files![0];
27 | if (!file) {
28 | throw new Error('No file selected');
29 | }
30 |
31 | return file.text();
32 | }
33 |
34 | async function saveFileOld(text: string, suggestedName: string): Promise {
35 | // Use data URL because Safari doesn't support saving blob URLs
36 | // Use base64 or else linebreaks are lost
37 | const url = `data:application/json;base64,${stringToBase64(text)}`;
38 | const link = document.createElement('a');
39 | link.download = suggestedName;
40 | link.href = url;
41 | link.click();
42 | }
43 |
44 | async function loadFileModern(): Promise {
45 | const [fileHandle] = await showOpenFilePicker(filePickerOptions);
46 | const file = await fileHandle.getFile();
47 | return file.text();
48 | }
49 |
50 | async function saveFileModern(text: string, suggestedName: string) {
51 | const fileHandle = await showSaveFilePicker({
52 | ...filePickerOptions,
53 | suggestedName,
54 | });
55 |
56 | const writable = await fileHandle.createWritable();
57 | await writable.write(text);
58 | await writable.close();
59 | }
60 |
61 | export const loadFile = isModern ? loadFileModern : loadFileOld;
62 | export const saveFile = isModern ? saveFileModern : saveFileOld;
63 |
--------------------------------------------------------------------------------
/globals.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'dom-form-serializer/dist/dom-form-serializer.mjs' {
2 | import {type JsonObject} from 'type-fest';
3 |
4 | export function serialize(
5 | element: HTMLFormElement,
6 | options: {
7 | include?: string[];
8 | }
9 | ): JSONValue;
10 |
11 | export function deserialize(
12 | element: HTMLFormElement,
13 | serializedData: JsonObject,
14 | options?: {
15 | include?: string[];
16 | }
17 | ): void;
18 | }
19 |
--------------------------------------------------------------------------------
/index.ts:
--------------------------------------------------------------------------------
1 | import {debounce} from 'throttle-debounce';
2 | import chromeP from 'webext-polyfill-kinda';
3 | import {isBackground} from 'webext-detect';
4 | import {serialize, deserialize} from 'dom-form-serializer/dist/dom-form-serializer.mjs';
5 | import LZString from 'lz-string';
6 | import {onContextInvalidated} from 'webext-events';
7 | import {loadFile, saveFile} from './file.js';
8 |
9 | // eslint-disable-next-line @typescript-eslint/naming-convention -- CJS in ESM imports
10 | const {compressToEncodedURIComponent, decompressFromEncodedURIComponent} = LZString;
11 |
12 | function alertAndThrow(message: string): never {
13 | // eslint-disable-next-line no-alert
14 | alert(message);
15 | throw new Error(message);
16 | }
17 |
18 | async function shouldRunMigrations(): Promise {
19 | const self = await chromeP.management?.getSelf();
20 |
21 | // Always run migrations during development #25
22 | if (self?.installType === 'development') {
23 | return true;
24 | }
25 |
26 | return new Promise(resolve => {
27 | // Run migrations when the extension is installed or updated
28 | chrome.runtime.onInstalled.addListener(() => {
29 | resolve(true);
30 | });
31 |
32 | // If `onInstalled` isn't fired, then migrations should not be run
33 | setTimeout(resolve, 500, false);
34 | });
35 | }
36 |
37 | export type StorageType = 'sync' | 'local';
38 |
39 | /**
40 | @example
41 | {
42 | // Recommended
43 | defaults: {
44 | color: 'blue'
45 | },
46 | // Optional
47 | migrations: [
48 | savedOptions => {
49 | if (savedOptions.oldStuff) {
50 | delete savedOptions.oldStuff;
51 | }
52 | }
53 | ],
54 | }
55 | */
56 | // eslint-disable-next-line @typescript-eslint/consistent-type-definitions -- Maybe later
57 | export interface Setup {
58 | storageName?: string;
59 | logging?: boolean;
60 | defaults?: UserOptions;
61 | /**
62 | * A list of functions to call when the extension is updated.
63 | */
64 | migrations?: Array>;
65 | storageType?: StorageType;
66 | }
67 |
68 | /**
69 | A map of options as strings or booleans. The keys will have to match the form fields' `name` attributes.
70 | */
71 | // eslint-disable-next-line @typescript-eslint/consistent-indexed-object-style, @typescript-eslint/consistent-type-definitions -- Interfaces are extendable
72 | export interface Options {
73 | [key: string]: string | number | boolean;
74 | }
75 |
76 | /*
77 | Handler signature for when an extension updates.
78 | */
79 | export type Migration = (savedOptions: UserOptions, defaults: UserOptions) => Promise | void;
80 |
81 | class OptionsSync {
82 | public static migrations = {
83 | /**
84 | Helper method that removes any option that isn't defined in the defaults. It's useful to avoid leaving old options taking up space.
85 | */
86 | removeUnused(options: Options, defaults: Options) {
87 | for (const key of Object.keys(options)) {
88 | if (!(key in defaults)) {
89 | delete options[key];
90 | }
91 | }
92 | },
93 | };
94 |
95 | storageName: string;
96 | storageType: StorageType;
97 |
98 | defaults: UserOptions;
99 |
100 | _form: HTMLFormElement | undefined;
101 |
102 | private readonly _migrations: Promise;
103 |
104 | /**
105 | @constructor Returns an instance linked to the chosen storage.
106 | @param setup - Configuration for `webext-options-sync`
107 | */
108 | constructor({
109 | // `as` reason: https://github.com/fregante/webext-options-sync/pull/21#issuecomment-500314074
110 | defaults = {} as UserOptions,
111 | storageName = 'options',
112 | migrations = [],
113 | logging = true,
114 | storageType = 'sync',
115 | }: Setup = {}) {
116 | this.storageName = storageName;
117 | this.defaults = defaults;
118 | this.storageType = storageType;
119 |
120 | if (!logging) {
121 | this._log = () => {};
122 | }
123 |
124 | this._migrations = this._runMigrations(migrations);
125 | }
126 |
127 | private get storage(): chrome.storage.StorageArea {
128 | return chromeP.storage[this.storageType];
129 | }
130 |
131 | /**
132 | Retrieves all the options stored.
133 |
134 | @returns Promise that will resolve with **all** the options stored, as an object.
135 |
136 | @example
137 | const optionsStorage = new OptionsSync();
138 | const options = await optionsStorage.getAll();
139 | console.log('The user’s options are', options);
140 | if (options.color) {
141 | document.body.style.color = color;
142 | }
143 | */
144 | async getAll(): Promise {
145 | await this._migrations;
146 | return this._getAll();
147 | }
148 |
149 | /**
150 | Overrides **all** the options stored with your `options`.
151 |
152 | @param newOptions - A map of default options as strings or booleans. The keys will have to match the form fields' `name` attributes.
153 | */
154 | async setAll(newOptions: UserOptions): Promise {
155 | await this._migrations;
156 | return this._setAll(newOptions);
157 | }
158 |
159 | /**
160 | Merges new options with the existing stored options.
161 |
162 | @param newOptions - A map of default options as strings or booleans. The keys will have to match the form fields' `name` attributes.
163 | */
164 | async set(newOptions: Partial): Promise {
165 | return this.setAll({...await this.getAll(), ...newOptions});
166 | }
167 |
168 | /**
169 | Any defaults or saved options will be loaded into the `