From 8267e8e9d6abc42dcbce595dd825b73353990d29 Mon Sep 17 00:00:00 2001 From: Aaron Klinker Date: Sun, 14 May 2023 10:22:18 -0500 Subject: [PATCH] Squashed commit of the following: commit 5e7b2be16fcb13483cbfc15f2bfd125901b80393 Author: Aaron Klinker Date: Sun May 14 10:08:22 2023 -0500 docs: Add publish action back to homepage commit 7a2b5cbd144d8bbe382e8d5aa4ef0782adc7b332 Author: Aaron Date: Sun May 14 10:01:55 2023 -0500 docs: Add links to symbols (#32) commit 26972a6f9e7a4a28cd5990a05445a1699b1d608b Author: Aaron Date: Sat May 13 10:10:19 2023 -0500 docs: Update links commit 55b84362cd8e73167d9da7adb571d86db2b9759a Author: Aaron Date: Sat May 13 10:05:49 2023 -0500 New Job Scheduler Package (#31) commit 5641e011a1fee68e050edc92c60b0fc5581bf3a1 Author: Aaron Klinker Date: Wed May 10 22:05:08 2023 -0500 docs: Update social image commit c1bfb5d7fbd2d04a82fbf25f9bb0367b947b1aa5 Author: Aaron Date: Wed May 10 19:48:55 2023 -0500 docs: Generate API reference (#30) commit e11d912cf6b8d8bcf5eaf5072db91facee7efb36 Author: Changelog Action Date: Tue May 2 16:02:07 2023 +0000 chore(release): proxy-service-v1.2.0 commit bbb1bc2f31c66681441102c3e160122585b0a39a Author: Aaron Date: Tue May 2 10:58:23 2023 -0500 feat(proxy-service): Export `flattenPromise` helper function (#26) --- .github/workflows/publish-packages.yml | 2 +- .prettierignore | 1 + README.md | 11 +- docs/.vitepress/config.ts | 238 ++++---- docs/.vitepress/plugins/typescript-docs.ts | 508 ++++++++++++++++ docs/api/fake-browser.md | 31 + docs/api/isolated-element.md | 78 +++ docs/api/job-scheduler.md | 171 ++++++ docs/api/messaging.md | 158 +++++ docs/api/proxy-service.md | 110 ++++ docs/api/storage.md | 93 +++ docs/guide/browser-support.md | 39 +- docs/guide/contributing.md | 28 +- .../fake-browser/implemented-apis.md | 6 + docs/{ => guide}/fake-browser/index.md | 4 +- .../fake-browser/reseting-state.md | 0 .../fake-browser/testing-frameworks.md | 0 .../fake-browser/triggering-events.md | 0 docs/guide/index.md | 22 +- docs/{ => guide}/isolated-element/index.md | 17 +- docs/guide/job-scheduler/index.md | 182 ++++++ docs/{ => guide}/messaging/index.md | 10 +- docs/{ => guide}/messaging/protocol-maps.md | 41 +- .../proxy-service/defining-services.md} | 12 +- docs/{ => guide}/proxy-service/index.md | 14 +- docs/guide/storage/index.md | 69 +++ docs/{ => guide}/storage/typescript.md | 40 +- docs/index.md | 21 +- docs/messaging/api.md | 83 --- docs/proxy-service/api.md | 104 ---- docs/storage/api.md | 183 ------ docs/storage/index.md | 144 ----- package.json | 10 +- packages/fake-browser/README.md | 2 +- packages/fake-browser/src/apis/alarms.test.ts | 18 +- packages/fake-browser/src/apis/alarms.ts | 6 +- packages/fake-browser/src/index.ts | 3 + packages/fake-browser/src/types.ts | 3 + packages/isolated-element/README.md | 2 +- packages/isolated-element/src/index.ts | 22 + packages/isolated-element/src/options.ts | 14 + packages/job-scheduler/README.md | 9 + packages/job-scheduler/package.json | 65 ++ .../src/__mocks__/webextension-polyfill.ts | 1 + packages/job-scheduler/src/index.test.ts | 286 +++++++++ packages/job-scheduler/src/index.ts | 270 +++++++++ packages/job-scheduler/tsconfig.json | 7 + packages/messaging/README.md | 2 +- packages/messaging/src/extension.ts | 3 + packages/messaging/src/generic.ts | 5 + packages/messaging/src/types.ts | 21 +- packages/proxy-service/README.md | 2 +- packages/proxy-service/package.json | 2 +- .../proxy-service/src/defineProxyService.ts | 12 + .../proxy-service/src/flattenPromise.test.ts | 60 ++ packages/proxy-service/src/flattenPromise.ts | 51 ++ packages/proxy-service/src/index.ts | 1 + packages/proxy-service/src/types.ts | 11 +- packages/storage/README.md | 2 +- .../storage/src/defineExtensionStorage.ts | 29 +- packages/storage/src/index.ts | 9 + packages/storage/src/types.ts | 6 + pnpm-lock.yaml | 558 +++++++++++++++--- 63 files changed, 3034 insertions(+), 878 deletions(-) create mode 100644 docs/.vitepress/plugins/typescript-docs.ts create mode 100644 docs/api/fake-browser.md create mode 100644 docs/api/isolated-element.md create mode 100644 docs/api/job-scheduler.md create mode 100644 docs/api/messaging.md create mode 100644 docs/api/proxy-service.md create mode 100644 docs/api/storage.md rename docs/{ => guide}/fake-browser/implemented-apis.md (97%) rename docs/{ => guide}/fake-browser/index.md (72%) rename docs/{ => guide}/fake-browser/reseting-state.md (100%) rename docs/{ => guide}/fake-browser/testing-frameworks.md (100%) rename docs/{ => guide}/fake-browser/triggering-events.md (100%) rename docs/{ => guide}/isolated-element/index.md (66%) create mode 100644 docs/guide/job-scheduler/index.md rename docs/{ => guide}/messaging/index.md (93%) rename docs/{ => guide}/messaging/protocol-maps.md (53%) rename docs/{proxy-service/variants.md => guide/proxy-service/defining-services.md} (94%) rename docs/{ => guide}/proxy-service/index.md (91%) create mode 100644 docs/guide/storage/index.md rename docs/{ => guide}/storage/typescript.md (52%) delete mode 100644 docs/messaging/api.md delete mode 100644 docs/proxy-service/api.md delete mode 100644 docs/storage/api.md delete mode 100644 docs/storage/index.md create mode 100644 packages/job-scheduler/README.md create mode 100644 packages/job-scheduler/package.json create mode 100644 packages/job-scheduler/src/__mocks__/webextension-polyfill.ts create mode 100644 packages/job-scheduler/src/index.test.ts create mode 100644 packages/job-scheduler/src/index.ts create mode 100644 packages/job-scheduler/tsconfig.json create mode 100644 packages/proxy-service/src/flattenPromise.test.ts create mode 100644 packages/proxy-service/src/flattenPromise.ts diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml index f355dbf..8290fba 100644 --- a/.github/workflows/publish-packages.yml +++ b/.github/workflows/publish-packages.yml @@ -14,7 +14,7 @@ jobs: strategy: max-parallel: 1 matrix: - package: [fake-browser, messaging, storage, proxy-service, isolated-element] + package: [fake-browser, messaging, storage, proxy-service, isolated-element, job-scheduler] runs-on: ubuntu-22.04 steps: - name: Checkout Repo diff --git a/.prettierignore b/.prettierignore index eb44b69..05bbd30 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,3 +3,4 @@ dist/ lib/ pnpm-lock.yaml /docs/.vitepress/cache +/docs/api/* diff --git a/README.md b/README.md index 398bcf4..0cc9a30 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,12 @@ A set of core libraries and tools for building web extensions. These libraries are built on top of [`webextension-polyfill`](https://www.npmjs.com/package/webextension-polyfill) and support all browsers. -- [`@webext-core/messaging`](https://webext-core.aklinker1.io/messaging): Light weight, type-safe wrapper around the web extension messaging APIs -- [`@webext-core/storage`](https://webext-core.aklinker1.io/storage): Local storage based, **type-safe** wrappers around the storage API -- [`@webext-core/fake-browser`](https://webext-core.aklinker1.io/fake-browser): An in-memory implementation of `webextension-polyfill` for testing -- [`@webext-core/proxy-service`](https://webext-core.aklinker1.io/proxy-service): Write services that can be called from any JS context, but run in the background service worker -- [`@webext-core/isolated-element`](https://webext-core.aklinker1.io/isolated-element): Isolate content script UIs from the page's styles +- [`@webext-core/messaging`](https://webext-core.aklinker1.io/guide/messaging/): Light weight, type-safe wrapper around the web extension messaging APIs +- [`@webext-core/storage`](https://webext-core.aklinker1.io/guide/storage/): Local storage based, **type-safe** wrappers around the storage API +- [`@webext-core/job-scheduler`](https://webext-core.aklinker1.io/guide/job-scheduler/): Schedule reoccuring jobs using the Alarms API +- [`@webext-core/fake-browser`](https://webext-core.aklinker1.io/guide/fake-browser/): An in-memory implementation of `webextension-polyfill` for testing +- [`@webext-core/proxy-service`](https://webext-core.aklinker1.io/guide/proxy-service/): Write services that can be called from any JS context, but run in the background service worker +- [`@webext-core/isolated-element`](https://webext-core.aklinker1.io/guide/isolated-element/): Isolate content script UIs from the page's styles ## Documentation diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 33c8c3a..92209cf 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -1,21 +1,100 @@ import { defineConfig } from 'vitepress'; +import { defineTypescriptDocs } from './plugins/typescript-docs'; const ogDescription = 'Next Generation Frontend Tooling'; const ogTitle = 'Web Ext Core'; const ogUrl = 'https://webext-core.aklinker1.io'; -const packages = { - text: 'Packages', - items: [ - { text: 'storage', link: '/storage/' }, - { text: 'messaging', link: '/messaging/' }, - { text: 'fake-browser', link: '/fake-browser/' }, - { text: 'proxy-service', link: '/proxy-service/' }, - { text: 'isolated-element', link: '/isolated-element/' }, - ].sort((l, r) => l.text.localeCompare(r.text)), +const packageDirnames = [ + 'storage', + 'messaging', + 'job-scheduler', + 'proxy-service', + 'isolated-element', + 'fake-browser', +]; + +const packagePages = { + 'fake-browser': [ + { + text: 'Get Started', + link: '/guide/fake-browser/', + }, + { + text: 'Testing Frameworks', + link: '/guide/fake-browser/testing-frameworks', + }, + { + text: 'Reseting State', + link: '/guide/fake-browser/reseting-state', + }, + { + text: 'Triggering Events', + link: '/guide/fake-browser/triggering-events', + }, + { + text: 'Implemented APIs', + link: '/guide/fake-browser/implemented-apis', + }, + ], + 'isolated-element': [ + { + text: 'Get Started', + link: '/guide/isolated-element/', + }, + ], + messaging: [ + { + text: 'Get Started', + link: '/guide/messaging/', + }, + { + text: 'Protocol Maps', + link: '/guide/messaging/protocol-maps', + }, + ], + 'proxy-service': [ + { + text: 'Get Started', + link: '/guide/proxy-service/', + }, + { + text: 'Defining Services', + link: '/guide/proxy-service/defining-services', + }, + ], + storage: [ + { + text: 'Get Started', + link: '/guide/storage/', + }, + { + text: 'Typescript', + link: '/guide/storage/typescript', + }, + ], + 'job-scheduler': [ + { + text: 'Get Started', + link: '/guide/job-scheduler/', + }, + ], +}; + +const packagesItemGroup = packageDirnames.map(dirname => ({ + text: dirname, + link: `/guide/${dirname}/`, + items: packagePages[dirname], +})); + +const apiItemGroup = { + text: 'API', + items: packageDirnames.map(dirname => ({ text: dirname, link: `/api/${dirname}` })), }; export default defineConfig({ + ...defineTypescriptDocs(packageDirnames), + title: `Web Ext Core`, description: 'Web Extension Development Made Easy', @@ -26,8 +105,14 @@ export default defineConfig({ // ['meta', { property: 'og:image', content: ogImage }], ['meta', { property: 'og:url', content: ogUrl }], ['meta', { property: 'og:description', content: ogDescription }], - ['meta', { name: 'twitter:card', content: 'summary_large_image' }], - ['meta', { name: 'twitter:site', content: '@vite_js' }], + [ + 'meta', + { + name: 'twitter:card', + content: + 'https://repository-images.githubusercontent.com/562524328/c0cd6d4b-23ff-4536-97ab-f19a57cc23e3', + }, + ], ['meta', { name: 'theme-color', content: '#646cff' }], // [ @@ -44,6 +129,15 @@ export default defineConfig({ themeConfig: { logo: '/logo.svg', + search: { + provider: 'algolia', + options: { + appId: 'W9IBYBNTPJ', + apiKey: 'c8c2d0d5e6f058c31b8539fc58e259af', + indexName: 'webext-core-docs', + }, + }, + editLink: { pattern: 'https://github.com/aklinker1/webext-core/edit/main/docs/:path', text: 'Suggest changes to this page', @@ -56,129 +150,25 @@ export default defineConfig({ copyright: 'Copyright © 2022-present Aaron Klinker & Web Ext Core Contributors', }, - nav: [{ text: 'Guide', link: '/guide/' }, packages], + nav: [{ text: 'Guide', link: '/guide/' }, apiItemGroup], sidebar: { '/guide/': [ { - text: 'Guide', - items: [ - { - text: 'Getting Started', - link: '/guide/', - }, - { - text: 'Browser Support', - link: '/guide/browser-support', - }, - { - text: 'Contributing', - link: '/guide/contributing', - }, - ], + text: 'Introduction', + link: '/guide/', }, - packages, - ], - '/fake-browser/': [ { - text: 'fake-browser', - items: [ - { - text: 'Get Started', - link: '/fake-browser/', - }, - { - text: 'Testing Frameworks', - link: '/fake-browser/testing-frameworks', - }, - { - text: 'Reseting State', - link: '/fake-browser/reseting-state', - }, - { - text: 'Triggering Events', - link: '/fake-browser/triggering-events', - }, - { - text: 'Implemented APIs', - link: '/fake-browser/implemented-apis', - }, - ], + text: 'Browser Support', + link: '/guide/browser-support', }, - packages, - ], - '/isolated-element/': [ - { - text: 'isolated-element', - items: [ - { - text: 'Get Started', - link: '/isolated-element/', - }, - ], - }, - packages, - ], - '/messaging/': [ - { - text: 'messaging', - items: [ - { - text: 'Get Started', - link: '/messaging/', - }, - { - text: 'Protocol Maps', - link: '/messaging/protocol-maps', - }, - { - text: 'API', - link: '/messaging/api', - }, - ], - }, - packages, - ], - '/proxy-service/': [ - { - text: 'proxy-service', - items: [ - { - text: 'Get Started', - link: '/proxy-service/', - }, - { - text: 'Variants', - link: '/proxy-service/variants', - }, - { - text: 'API', - link: '/proxy-service/api', - }, - ], - }, - packages, - ], - '/storage/': [ { - text: 'storage', - items: [ - { - text: 'Get Started', - link: '/storage/', - }, - { - text: 'API', - link: '/storage/api', - }, - { - text: 'Typescript', - link: '/storage/typescript', - }, - ], + text: 'Contributing', + link: '/guide/contributing', }, - packages, + ...packagesItemGroup, ], + '/api/': [apiItemGroup], }, }, }); diff --git a/docs/.vitepress/plugins/typescript-docs.ts b/docs/.vitepress/plugins/typescript-docs.ts new file mode 100644 index 0000000..c26d395 --- /dev/null +++ b/docs/.vitepress/plugins/typescript-docs.ts @@ -0,0 +1,508 @@ +import { UserConfig } from 'vitepress'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { Listr, ListrTask, ListrTaskWrapper } from 'listr2'; +import { Project, Symbol, SourceFile, Node, ts, JSDocableNode, JSDoc } from 'ts-morph'; +import * as prettier from 'prettier'; +import chokidar from 'chokidar'; +import { defineConfig } from 'vitepress'; +import { parseHTML } from 'linkedom'; + +export function defineTypescriptDocs(packageDirnames: string[]) { + const ctx: Ctx = { + watchers: [], + symbolMap: {}, + }; + + function plugin(): Plugin { + let mode: 'build' | 'serve'; + let hasGenerated = false; + + return { + name: 'generate-ts-docs', + configResolved(config) { + mode = config.command; + }, + async buildStart() { + if (hasGenerated) return; + hasGenerated = true; + + const allPackages = await getPackages(); + await generateAll(ctx, allPackages, mode, mode === 'serve'); + }, + async buildEnd() { + removeWatchListeners(ctx); + }, + }; + } + + return defineConfig({ + ignoreDeadLinks: [/^\/api\/.*/], + + vite: { + plugins: [plugin()], + define: { + __PACKAGES__: JSON.stringify(packageDirnames), + }, + }, + + transformHtml(code, id) { + const [_, thisPkg] = id.match(/\/api\/(.*)\.html$/) ?? []; + if (!thisPkg) return; + + const { document } = parseHTML(code); + /** + * Creates an anchor element to a symbol's package. + */ + const createLink = ( + symbolName: string, + thisPkg: string, + packages: string[], + ): HTMLAnchorElement => { + const a = document.createElement('a'); + a.textContent = symbolName; + if (packages.includes(thisPkg)) { + a.href = '#' + symbolName.toLowerCase(); + } else { + a.href = `/api/${packages[0]}#${symbolName.toLowerCase()}`; + } + return a; + }; + + // Code blocks - same text color, underlined + document.querySelectorAll('pre span').forEach(span => { + Object.entries(ctx.symbolMap).forEach(([symbolName, packages]) => { + if (span.textContent !== symbolName) return; + + const a = createLink(symbolName, thisPkg, packages); + a.style.color = 'inherit'; + a.style.textDecoration = 'underline'; + span.replaceChildren(a); + }); + }); + + // Inline code - primary color, underlined links + document.querySelectorAll('p code, li code').forEach(block => + Object.entries(ctx.symbolMap).forEach(([symbolName, packages]) => { + // Look for matching full word + if ( + !block.textContent + ?.replace(/[.,\/#!$%\^&\*;:{}=\-_`~()]/g, '') + .split(/\s+/) + .includes(symbolName) + ) + return; + + const a = createLink(symbolName, thisPkg, packages); + a.style.textDecoration = 'underline'; + block.innerHTML = block.innerHTML.replace(symbolName, a.outerHTML); + }), + ); + + return document.toString(); + }, + }); +} + +type Plugin = NonNullable['plugins']>[0]; +type SymbolLinks = { [symbolName: string]: string }; + +const EXTERNAL_SYMBOLS: SymbolLinks = { + Promise: + 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise', + 'Storage.StorageArea': + 'https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/storage/StorageArea', + StorageArea: + 'https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/storage/StorageArea', +}; + +const EXCLUDED_SYMBOLS = [ + // name of an anonymous type + '__type', + // Generic type params + 'TData', + 'TReturn', +]; + +/** + * context passed into each task + */ +interface Ctx { + watchers: chokidar.FSWatcher[]; + /** + * Map of symbols to the packages they are found in. + */ + symbolMap: { [symbolName: string]: string[] }; +} + +async function removeWatchListeners(ctx: Ctx) { + for (const w of ctx.watchers) { + await w.close(); + } + ctx.watchers = []; +} + +/** + * Return the package folder names that documentation will be generated for. + */ +async function getPackages(): Promise { + const all = await fs.readdir('packages'); + return all.filter(folderName => !folderName.endsWith('-demo') && folderName !== 'tsconfig'); +} + +async function generateAll( + ctx: Ctx, + projectDirNames: string[], + mode: 'build' | 'serve', + watch?: boolean, +) { + if (watch) { + await removeWatchListeners(ctx); + } + + const tasks = new Listr( + { + title: 'Generating TS Docs', + task: (ctx, topTask) => + topTask.newListr( + projectDirNames.map>(dirname => ({ + title: dirname, + task: (ctx, task) => { + if (watch) { + const watcher = chokidar.watch(`packages/${dirname}/src`); + watcher.on('change', changedPath => { + console.log( + `\n\x1b[2mChanged: ${path.relative(process.cwd(), changedPath)}\x1b[0m`, + ); + generateAll(ctx, [dirname], mode); + }); + ctx.watchers.push(watcher); + } + + return generateProjectDocs(ctx, task, dirname); + }, + })), + ), + }, + { + ctx, + silentRendererCondition: () => mode === 'build', + }, + ); + + try { + await tasks.run(); + } catch (e) { + console.error(e); + } finally { + } +} + +async function generateProjectDocs( + ctx: Ctx, + task: ListrTaskWrapper, + projectDirname: string, +) { + // Project setup + + const projectDir = path.resolve('packages', projectDirname); + const entrypointPath = path.resolve(projectDir, 'src/index.ts'); + const outputDir = path.resolve('docs/api'); + const outputFile = path.resolve(outputDir, `${projectDirname}.md`); + const tsConfigFilePath = path.resolve(projectDir, 'tsconfig.json'); + // Load TS Project + const project = new Project({ tsConfigFilePath }); + const entrypoint = project.addSourceFileAtPath(entrypointPath); + project.resolveSourceFileDependencies(); + + // Type Generation + + const publicSymbols = getPublicSymbols(project, entrypoint); + // Sort alphabetically + publicSymbols.sort((l, r) => l.getName().toLowerCase().localeCompare(r.getName().toLowerCase())); + const docs = renderDocs(projectDirname, project, publicSymbols); + publicSymbols.forEach(s => { + (ctx.symbolMap[s.getName()] ??= []).push(projectDirname); + }); + // Write to file + await fs.mkdir(outputDir, { recursive: true }); + await fs.writeFile(outputFile, docs, 'utf-8'); +} + +function getPublicSymbols(project: Project, entrypoint: SourceFile): Symbol[] { + // Get all exported declarations from the source file + const exportedDeclarations = entrypoint.getExportedDeclarations(); + + // Collect all exported symbols + const exportedSymbols: Symbol[] = []; + for (const declarations of exportedDeclarations.values()) { + for (const declaration of declarations) { + exportedSymbols.push(declaration.getSymbolOrThrow()); + } + } + + // Collect all referenced symbols in exported declarations + const referencedSymbols: Symbol[] = []; + for (const declaration of exportedSymbols) { + const declarationNode = declaration.getDeclarations()[0]; + collectReferencedSymbols(declarationNode, referencedSymbols); + } + + // Combine and return the package's exported and referenced symbols + const allSymbols = Array.from(new Set([...exportedSymbols, ...referencedSymbols])); + return allSymbols.filter( + s => !EXTERNAL_SYMBOLS[s.getName()] && !EXCLUDED_SYMBOLS.includes(s.getName()), + ); +} + +function collectReferencedSymbols(node: Node, symbols: Symbol[]): void { + const symbol = node.getSymbolOrThrow(); + + if (node.isKind(ts.SyntaxKind.TypeReference)) { + symbols.push(symbol); + + // Check referenced type for additional referenced symbols + const type = node.getType(); + const typeSymbol = type.getSymbolOrThrow(); + symbols.push(typeSymbol); + + // Check type arguments for additional referenced symbols + node.getTypeArguments().forEach(typeArg => { + collectReferencedSymbols(typeArg, symbols); + }); + return; + } + + if (node.isKind(ts.SyntaxKind.InterfaceDeclaration)) { + symbols.push(symbol); + + // Check properties for additional referenced symbols + node.getProperties().forEach(property => { + collectReferencedSymbols(property, symbols); + }); + + // TODO: extends + return; + } + + if (node.isKind(ts.SyntaxKind.TypeAliasDeclaration)) { + symbols.push(symbol); + return; + } + + if (node.isKind(ts.SyntaxKind.PropertySignature)) { + const typeSymbol = node.getType().getSymbol(); + if (typeSymbol) symbols.push(typeSymbol); + return; + } + + if (node.isKind(ts.SyntaxKind.VariableDeclaration)) { + const typeSymbol = node.getType().getSymbol(); + if (typeSymbol) symbols.push(typeSymbol); + return; + } + + if (node.isKind(ts.SyntaxKind.FunctionDeclaration)) { + node.getParameters().forEach(parameter => collectReferencedSymbols(parameter, symbols)); + const returnTypeSymbol = node.getReturnType().getSymbol(); + if (returnTypeSymbol) symbols.push(returnTypeSymbol); + return; + } + + if (node.isKind(ts.SyntaxKind.Parameter)) { + const typeSymbol = node.getType().getSymbol(); + if (typeSymbol) symbols.push(typeSymbol); + return; + } + + warn(`Unknown kind, cannot extract symbols: ${node.getKindName()}`); +} + +function renderDocs(projectDirname: string, project: Project, symbols: Symbol[]): string { + const sections = [ + // Header + ``, + `# API`, + `API reference for [\`@webext-core/${projectDirname}\`](/guide/${projectDirname}/).`, + ':::info\nThe entire API reference is also available in your editor via [JSDocs](https://jsdoc.app/).\n:::', + // Symbols + ...symbols.map(symbol => renderSymbol(project, symbol).trim()), + // Footer + '

', + '---', + '_API reference generated by [`plugins/typescript-docs.ts`](https://github.com/aklinker1/webext-core/blob/main/docs/.vitepress/plugins/typescript-docs.ts)_', + ]; + return sections.join('\n\n'); +} + +function renderSymbol(project: Project, symbol: Symbol): string { + const { examples, description, parameters, returns, deprecated } = parseJsdoc(symbol); + const typeDefinition = getTypeDeclarations(project, symbol).join('\n\n'); + const properties: string[] = symbol + .getDeclarations() + .flatMap(dec => dec.asKind(ts.SyntaxKind.InterfaceDeclaration)) + .flatMap(i => i?.getProperties() ?? []) + .map(prop => { + const dec = prop.getSymbolOrThrow().getDeclarations()[0]; + const docs = prop + .getJsDocs() + .map(doc => doc.getCommentText()) + .join(' '); + const defaultValue = prop + .getSymbol() + ?.getJsDocTags() + .find(tag => tag.getName() === 'default') + ?.getText() + .map(part => part.text) + .join(' '); + return `***\`${dec.getText().replace(/;$/, '')}\`***${ + defaultValue ? ` (default: \`${defaultValue}\`)` : '' + }${docs ? '
' + docs : ''}`; + }); + + let template = ` +## \`${symbol.getName()}\` + +${deprecated ? `:::danger Deprecated\n${deprecated}\n:::` : ''} + +\`\`\`ts +${typeDefinition} +\`\`\` + +${description} + +${ + parameters.length > 0 + ? `### Parameters\n\n${parameters.map(parameter => `- ${parameter}`).join('\n\n')}` + : '' +} + +${returns ? `### Returns \n\n${returns}` : ''} + +${ + properties.length > 0 + ? `### Properties \n\n${properties.map(property => `- ${property}`).join('\n\n')}` + : '' +} + +${ + examples.length > 0 + ? `### Examples\n\n${examples.map(ex => `\`\`\`ts\n${ex}\n\`\`\``).join('\n\n')}` + : '' +} +`; + + while (template.includes('\n\n\n')) template = template.replace('\n\n\n', '\n\n'); + return template.replace(/\n\n\n/gm, '\n\n'); +} + +function cleanTypeText(text: string): string { + text = text + // Remove "export " from start + .replace(/^export /, '') + // Remove any inline JSDoc and whitespace + .replace(/\s*\/\*\*[\S\s]*?\*\//gm, ''); + + // Remove "import(...)." from types + text.match(/import\(.*?\)\./gm)?.forEach(importText => { + text = text.replace(importText, ''); + }); + return text; +} + +function getTypeDeclarations(project: Project, symbol: Symbol): string[] { + return symbol + .getDeclarations() + .flatMap(dec => { + // Remove body from function declarations. + if (dec.isKind(ts.SyntaxKind.FunctionDeclaration)) dec.setBodyText('// ...'); + + const text = cleanTypeText(dec.getText()); + + // text ~= "() => void" + if (dec.isKind(ts.SyntaxKind.FunctionType)) return text; + // text ~= "type Abc = Something" + if (dec.isKind(ts.SyntaxKind.TypeAliasDeclaration)) return text; + // text ~= "interface Abc { ... }" + if (dec.isKind(ts.SyntaxKind.InterfaceDeclaration)) return text; + // text ~= "function abc() { ... }" + if (dec.isKind(ts.SyntaxKind.FunctionDeclaration)) return text; + // text ~= "T" + if (dec.isKind(ts.SyntaxKind.TypeParameter)) return text; + // text ~= "varName = ..."; + if (dec.isKind(ts.SyntaxKind.VariableDeclaration)) { + const name = dec.getName(); + let declarationKeyword = dec.getVariableStatementOrThrow().getDeclarationKind(); + const type = cleanTypeText(dec.getType().getText()); + return `${declarationKeyword} ${name}: ${type}`; + } + + throw Error( + `ts.SyntaxKind.${dec.getKindName()} cannot convert to type declaration:\n${ + dec.getText().split('\n')[0] + }`, + ); + }) + .map(text => prettier.format(text, { printWidth: 80, parser: 'typescript' }).trimEnd()); +} + +function warn(message: string) { + console.log(`\x1b[1m\x1b[33m${message}\x1b[0m`); +} + +function parseJsdoc(symbol: Symbol) { + const dec = symbol.getDeclarations()[0] as Node; + const docsNode = dec.getFirstAncestorByKind(ts.SyntaxKind.VariableStatement) ?? dec; + let docs: JSDoc[] | undefined; + if ('getJsDocs' in docsNode) { + docs = (docsNode as unknown as JSDocableNode).getJsDocs(); + } + + const examples: string[] = symbol + .getJsDocTags() + .filter(tag => tag.getName() === 'example') + .flatMap(tag => tag.getText()) + .map(part => part.text); + + const functionDec = symbol.getDeclarations()[0].asKind(ts.SyntaxKind.FunctionDeclaration); + const parameters: string[] = symbol + .getJsDocTags() + .filter(tag => tag.getName() === 'param') + .map(tag => { + const parts = tag.getText(); + let name: string; + let docs: string[] = []; + if (parts.length === 1) { + name = parts[0].text; + } else { + name = parts.find(p => p.kind === 'parameterName')!.text; + docs = parts.filter(p => p.kind === 'text').map(p => p.text); + } + const type = functionDec?.getParameterOrThrow(name).print() ?? 'unknown'; + return `***\`${type}\`***${docs.length > 0 ? '
' + docs.join('\n\n') : ''}`; + }); + + const description: string | undefined = docs?.flatMap(doc => doc.getCommentText()).join('\n\n'); + + const returns = symbol + .getJsDocTags() + .find(tag => tag.getName() === 'returns') + ?.getText() + .map(part => part.text) + .join(' '); + + const deprecated = symbol + .getJsDocTags() + .find(tag => tag.getName() === 'deprecated') + ?.getText() + .map(part => part.text) + .join(' '); + + return { + examples, + description, + returns, + parameters, + deprecated, + }; +} diff --git a/docs/api/fake-browser.md b/docs/api/fake-browser.md new file mode 100644 index 0000000..c4fec82 --- /dev/null +++ b/docs/api/fake-browser.md @@ -0,0 +1,31 @@ + + +# API + +API reference for [`@webext-core/fake-browser`](/guide/fake-browser/). + +:::info +The entire API reference is also available in your editor via [JSDocs](https://jsdoc.app/). +::: + +## `FakeBrowser` + +```ts +type FakeBrowser = BrowserOverrides & Browser; +``` + +The standard `Browser` interface from `webextension-polyfill`, but with additional functions for triggering events and reseting state. + +## `fakeBrowser` + +```ts +const fakeBrowser: FakeBrowser; +``` + +An in-memory implementation of the `browser` global. + +

+ +--- + +_API reference generated by [`plugins/typescript-docs.ts`](https://github.com/aklinker1/webext-core/blob/main/docs/.vitepress/plugins/typescript-docs.ts)_ \ No newline at end of file diff --git a/docs/api/isolated-element.md b/docs/api/isolated-element.md new file mode 100644 index 0000000..4579479 --- /dev/null +++ b/docs/api/isolated-element.md @@ -0,0 +1,78 @@ + + +# API + +API reference for [`@webext-core/isolated-element`](/guide/isolated-element/). + +:::info +The entire API reference is also available in your editor via [JSDocs](https://jsdoc.app/). +::: + +## `createIsolatedElement` + +```ts +async function createIsolatedElement( + options: CreateIsolatedElementOptions +): Promise<{ + parentElement: HTMLElement; + isolatedElement: HTMLElement; + shadow: ShadowRoot; +}> { + // ... +} +``` + +Create an HTML element that has isolated styles from the rest of the page. + +### Parameters + +- ***`options: CreateIsolatedElementOptions`*** + +### Returns + +- A `parentElement` that can be added to the DOM +- The `shadow` root +- An `isolatedElement` that you should mount your UI to. + +### Examples + +```ts +const { isolatedElement, parentElement } = createIsolatedElement({ + name: 'example-ui', + css: { textContent: "p { color: red }" }, +}); + +// Create and mount your app inside the isolation +const ui = document.createElement("p"); +ui.textContent = "Example UI"; +isolatedElement.appendChild(ui); + +// Add the UI to the DOM +document.body.appendChild(parentElement); +``` + +## `CreateIsolatedElementOptions` + +```ts +interface CreateIsolatedElementOptions { + name: string; + mode?: "open" | "closed"; + css?: { url: string } | { textContent: string }; +} +``` + +Options that can be passed into `createIsolatedElement`. + +### Properties + +- ***`name: string`***
A unique tag name used when defining the web component used internally. Don't use the same name twice for different UIs. + +- ***`mode?: 'open' | 'closed'`*** (default: `'closed'`)
See [`ShadowRoot.mode`](https://developer.mozilla.org/en-US/docs/Web/API/ShadowRoot/mode). + +- ***`css?: { url: string } | { textContent: string }`***
Either the URL to a CSS file or the text contents of a CSS file. The styles will be mounted inside the shadow DOM so they don't effect the rest of the page. + +

+ +--- + +_API reference generated by [`plugins/typescript-docs.ts`](https://github.com/aklinker1/webext-core/blob/main/docs/.vitepress/plugins/typescript-docs.ts)_ \ No newline at end of file diff --git a/docs/api/job-scheduler.md b/docs/api/job-scheduler.md new file mode 100644 index 0000000..a3902d4 --- /dev/null +++ b/docs/api/job-scheduler.md @@ -0,0 +1,171 @@ + + +# API + +API reference for [`@webext-core/job-scheduler`](/guide/job-scheduler/). + +:::info +The entire API reference is also available in your editor via [JSDocs](https://jsdoc.app/). +::: + +## `CronJob` + +```ts +interface CronJob extends cron.ParserOptions { + id: string; + type: "cron"; + expression: string; + execute: ExecuteFn; +} +``` + +A job that is executed based on a CRON expression. Backed by `cron-parser`. + +[`cron.ParserOptions`](https://github.com/harrisiirak/cron-parser#options) includes options like timezone. + +### Properties + +- ***`id: string`*** + +- ***`type: 'cron'`*** + +- ***`expression: string`***
See `cron-parser`'s [supported expressions](https://github.com/harrisiirak/cron-parser#supported-format) + +- ***`execute: ExecuteFn`*** + +## `defineJobScheduler` + +```ts +function defineJobScheduler(options?: JobSchedulerConfig): JobScheduler { + // ... +} +``` + +> Requires the `alarms` permission. + +Creates a `JobScheduler` backed by the +[alarms API](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/alarms). + +### Parameters + +- ***`options?: JobSchedulerConfig`*** + +### Returns + +A `JobScheduler` that can be used to schedule and manage jobs. + +## `ExecuteFn` + +```ts +type ExecuteFn = () => Promise | any; +``` + +Function ran when executing the job. Errors are automatically caught and will trigger the +`"error"` event. If a value is returned, the result will be available in the `"success"` event. + +## `IntervalJob` + +```ts +interface IntervalJob { + id: string; + type: "interval"; + duration: number; + immediate?: boolean; + execute: ExecuteFn; +} +``` + +A job that executes on a set interval, starting when the job is scheduled for the first time. + +### Properties + +- ***`id: string`*** + +- ***`type: 'interval'`*** + +- ***`duration: number`***
Interval in milliseconds. Due to limitations of the alarms API, it must be greater than 1 +minute. + +- ***`immediate?: boolean`*** (default: `false`)
Execute the job immediately when it is scheduled for the first time. If `false`, it will +execute for the first time after `duration`. This has no effect when updating an existing job. + +- ***`execute: ExecuteFn`*** + +## `Job` + +```ts +type Job = IntervalJob | CronJob | OnceJob; +``` + +## `JobScheduler` + +```ts +interface JobScheduler { + scheduleJob(job: Job): Promise; + removeJob(jobId: string): Promise; + on( + event: "success", + callback: (job: Job, result: any) => void + ): RemoveListenerFn; + on( + event: "error", + callback: (job: Job, error: unknown) => void + ): RemoveListenerFn; +} +``` + +## `JobSchedulerConfig` + +```ts +interface JobSchedulerConfig { + logger?: Logger | null; +} +``` + +Configures how the job scheduler behaves. + +### Properties + +- ***`logger?: Logger | null`*** (default: `console`)
The logger to use when logging messages. Set to `null` to disable logging. + +## `Logger` + +```ts +interface Logger { + debug(...args: any[]): void; + log(...args: any[]): void; + warn(...args: any[]): void; + error(...args: any[]): void; +} +``` + +Interface used to log text to the console when creating and executing jobs. + +## `OnceJob` + +```ts +interface OnceJob { + id: string; + type: "once"; + date: Date | string | number; + execute: ExecuteFn; +} +``` + +Runs a job once, at a specific date/time. + +### Properties + +- ***`id: string`*** + +- ***`type: 'once'`*** + +- ***`date: Date | string | number`***
The date to run the job on. + +- ***`execute: ExecuteFn`*** + +

+ +--- + +_API reference generated by [`plugins/typescript-docs.ts`](https://github.com/aklinker1/webext-core/blob/main/docs/.vitepress/plugins/typescript-docs.ts)_ \ No newline at end of file diff --git a/docs/api/messaging.md b/docs/api/messaging.md new file mode 100644 index 0000000..7353e30 --- /dev/null +++ b/docs/api/messaging.md @@ -0,0 +1,158 @@ + + +# API + +API reference for [`@webext-core/messaging`](/guide/messaging/). + +:::info +The entire API reference is also available in your editor via [JSDocs](https://jsdoc.app/). +::: + +## `defineExtensionMessaging` + +```ts +function defineExtensionMessaging< + TProtocolMap = Record> +>(config?: ExtensionMessagingConfig): Messenger { + // ... +} +``` + +Creates a new `Messenger` used to send and recieve messages in your extension. + +### Parameters + +- ***`config?: ExtensionMessagingConfig`***
Configure the behavior of the messenger. + +## `ExtensionMessagingConfig` + +```ts +interface ExtensionMessagingConfig { + logger?: Logger | null; +} +``` + +Configure how the messenger behaves. + +### Properties + +- ***`logger?: Logger | null`*** (default: `console`)
The logger to use when logging messages. Set to `null` to disable logging. + +## `GetDataType` + +```ts +type GetDataType = T extends (...args: infer Args) => any + ? Args["length"] extends 0 | 1 + ? Args[0] + : never + : T extends ProtocolWithReturn + ? T["BtVgCTPYZu"] + : T; +``` + +Given a function declaration, `ProtocolWithReturn`, or a value, return the message's data type. + +## `GetReturnType` + +```ts +type GetReturnType = T extends (...args: any[]) => infer R + ? R + : T extends ProtocolWithReturn + ? T["RrhVseLgZW"] + : void; +``` + +Given a function declaration, `ProtocolWithReturn`, or a value, return the message's return type. + +## `Logger` + +```ts +interface Logger { + debug(...args: any[]): void; + log(...args: any[]): void; + warn(...args: any[]): void; + error(...args: any[]): void; +} +``` + +Interface used to log text to the console when sending and recieving messages. + +## `MaybePromise` + +```ts +type MaybePromise = Promise | T; +``` + +Either a Promise of a type, or that type directly. Used to indicate that a method can by sync or +async. + +## `Messenger` + +```ts +interface Messenger { + sendMessage( + type: TType, + data: GetDataType, + tabId?: number + ): Promise>; + onMessage( + type: TType, + onReceived: OnMessageReceived + ): RemoveListenerCallback; + removeAllMessageListeners(): void; +} +``` + +Use the functions defined in the messenger to send and recieve messages throughout your entire extension. + +Unlike the regular `chrome.runtime` messaging APIs, there are no limitations to when you call `onMessage` or `sendMessage`. + +## `ProtocolWithReturn` + +:::danger Deprecated +Use the function syntax instead: +::: + +```ts +interface ProtocolWithReturn { + BtVgCTPYZu: TData; + RrhVseLgZW: TReturn; +} +``` + +Used to add a return type to a message in the protocol map. + +> Internally, this is just an object with random keys for the data and return types. + +### Properties + +- ***`BtVgCTPYZu: TData`***
Stores the data type. Randomly named so that it isn't accidentally implemented. + +- ***`RrhVseLgZW: TReturn`***
Stores the return type. Randomly named so that it isn't accidentally implemented. + +### Examples + +```ts +interface ProtocolMap { + // data is a string, returns undefined + type1: string; + // data is a string, returns a number + type2: ProtocolWithReturn; +} +``` + +## `RemoveListenerCallback` + +```ts +type RemoveListenerCallback = () => void; +``` + +Call to ensure an active listener has been removed. + +If the listener has already been removed with `Messenger.removeAllListeners`, this is a noop. + +

+ +--- + +_API reference generated by [`plugins/typescript-docs.ts`](https://github.com/aklinker1/webext-core/blob/main/docs/.vitepress/plugins/typescript-docs.ts)_ \ No newline at end of file diff --git a/docs/api/proxy-service.md b/docs/api/proxy-service.md new file mode 100644 index 0000000..c223040 --- /dev/null +++ b/docs/api/proxy-service.md @@ -0,0 +1,110 @@ + + +# API + +API reference for [`@webext-core/proxy-service`](/guide/proxy-service/). + +:::info +The entire API reference is also available in your editor via [JSDocs](https://jsdoc.app/). +::: + +## `DeepAsync` + +```ts +type DeepAsync = TService extends (...args: any) => any + ? ToAsyncFunction + : TService extends { [key: string]: any } + ? { + [fn in keyof TService]: DeepAsync; + } + : never; +``` + +A recursive type that deeply converts all methods in `TService` to be async. + +## `defineProxyService` + +```ts +function defineProxyService( + name: string, + init: (...args: TArgs) => TService, + config?: ProxyServiceConfig +): [ + registerService: (...args: TArgs) => TService, + getService: () => ProxyService +] { + // ... +} +``` + +Utility for creating a service whose functions are executed in the background script regardless +of the JS context the they are called from. + +### Parameters + +- ***`name: string`***
A unique name for the service. Used to identify which service is being executed. + +- ***`init: (...args: TArgs) => TService`***
A function that returns your real service implementation. If args are listed, +`registerService` will require the same set of arguments. + +- ***`config?: ProxyServiceConfig`*** + +### Returns + +- `registerService`: Used to register your service in the background +- `getService`: Used to get an instance of the service anywhere in the extension. + +## `flattenPromise` + +```ts +function flattenPromise(promise: Promise): DeepAsync { + // ... +} +``` + +Given a promise of a variable, return a proxy to that awaits the promise internally so you don't +have to call `await` twice. + +> This can be used to simplify handling `Promise` passed in your services. + +### Examples + +```ts +function createService(dependencyPromise: Promise) { + const dependency = flattenPromise(dependencyPromise); + + return { + doSomething() { + await dependency.someAsyncWork(); + // Instead of `await (await dependencyPromise).someAsyncWork();` + } + } +} +``` + +## `ProxyService` + +```ts +type ProxyService = TService extends DeepAsync + ? TService + : DeepAsync; +``` + +A type that ensures a service has only async methods. +- ***If all methods are async***, it returns the original type. +- ***If the service has non-async methods***, it returns a `DeepAsync` of the service. + +## `ProxyServiceConfig` + +```ts +interface ProxyServiceConfig extends ExtensionMessagingConfig {} +``` + +Configure a proxy service's behavior. It uses `@webext-core/messaging` internally, so any +config from `ExtensionMessagingConfig` can be passed as well. + +

+ +--- + +_API reference generated by [`plugins/typescript-docs.ts`](https://github.com/aklinker1/webext-core/blob/main/docs/.vitepress/plugins/typescript-docs.ts)_ \ No newline at end of file diff --git a/docs/api/storage.md b/docs/api/storage.md new file mode 100644 index 0000000..5399659 --- /dev/null +++ b/docs/api/storage.md @@ -0,0 +1,93 @@ + + +# API + +API reference for [`@webext-core/storage`](/guide/storage/). + +:::info +The entire API reference is also available in your editor via [JSDocs](https://jsdoc.app/). +::: + +## `defineExtensionStorage` + +```ts +function defineExtensionStorage( + storage: Storage.StorageArea +): ExtensionStorage { + // ... +} +``` + +Create a storage instance with an optional schema, `TSchema`, for type safety. + +### Parameters + +- ***`storage: Storage.StorageArea`***
The storage to to use. Either `Browser.storage.local`, `Browser.storage.sync`, or `Browser.storage.managed`. + +### Examples + +```ts +import browser from 'webextension-polyfill'; + +interface Schema { + installDate: number; +} +const extensionStorage = defineExtensionStorage(browser.storage.local); + +const date = await extensionStorage.getItem("installDate"); +``` + +## `ExtensionStorage` + +```ts +interface ExtensionStorage { + clear(): Promise; + getItem( + key: TKey + ): Promise[TKey] | null>; + setItem( + key: TKey, + value: TSchema[TKey] + ): Promise; + removeItem(key: TKey): Promise; + onChange( + key: TKey, + cb: OnChangeCallback + ): RemoveListenerCallback; +} +``` + +This is the interface for the storage objects exported from the package. It is similar to `localStorage`, except for a few differences: + +- ***It's async*** since the web extension storage APIs are async. +- It can store any data type, ***not just strings***. + +## `localExtStorage` + +```ts +const localExtStorage: ExtensionStorage; +``` + +An implementation of `ExtensionStorage` based on the `browser.storage.local` storage area. + +## `managedExtStorage` + +```ts +const managedExtStorage: ExtensionStorage; +``` + +An implementation of `ExtensionStorage` based on the `browser.storage.managed` storage area. + +## `syncExtStorage` + +```ts +const syncExtStorage: ExtensionStorage; +``` + +An implementation of `ExtensionStorage` based on the `browser.storage.sync` storage area. + +

+ +--- + +_API reference generated by [`plugins/typescript-docs.ts`](https://github.com/aklinker1/webext-core/blob/main/docs/.vitepress/plugins/typescript-docs.ts)_ \ No newline at end of file diff --git a/docs/guide/browser-support.md b/docs/guide/browser-support.md index 23c824b..4169dae 100644 --- a/docs/guide/browser-support.md +++ b/docs/guide/browser-support.md @@ -2,34 +2,17 @@ ## Overview -All `@webext-core` packages will work on: +The `@webext-core` packages are simple wrappers around [`webextension-polyfill`](https://www.npmjs.com/package/webextension-polyfill) by Mozilla. As such, they will work on: -- **Google Chrome** (and other Chromium-based browsers) -- **Firefox** +| Browser | Supported Versions | +| --------------------- | :----------------: | +| Chrome | >= 87 | +| Firefox | >= 78 | +| Safari _1_ | >= 14 | +| Edge | >= 88 | -The packages are just wrappers around the [`webextension-polyfill`](https://www.npmjs.com/package/webextension-polyfill) by Mozilla. +Other Chromium-based browsers are not officially supported, and may not work*2*. See Mozilla's [supported browsers documentation](https://github.com/mozilla/webextension-polyfill#supported-browsers) for more details. -See their [supported browsers documentation](https://github.com/mozilla/webextension-polyfill#supported-browsers) for more details. - -### Safari? - -Safari define's both Chrome's `chrome` and Firefox's `browser` globals, but neither is a complete implemenation of the web extension standard. - -`@webext-core` packages will work on Safari as long as you are using one of the implemented APIs. See the [browser compatibliity chart](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Browser_support_for_JavaScript_APIs) for more details. - -## The Future of MV3 - -Mozilla has confirmed that they will not be removing some of the APIs from Firefox that Google is removing from Chrome in MV3, like the `webRequest` API. - -Right now, the way `webextension-polyfill` handles missing APIs is the `browser.apiName` object becomes `undefined`. - -A good example of this is the MV2 `browserAction` and MV3 `action` APIs. Since MV3 dropped support for `browserAction`, you just need to check if the API exists before using it: - -```ts -import browser from 'webextension-polyfill' - -const action = browser.action ?? browser.browserAction; -action.setIcon({ ... }); -``` - -It's likely that as MV3 is finalized and adopted, a similar approach will need to be taken for APIs that are only available on specific browsers. +> _1_ - `webextension-polyfill` works on Safari, however Safari does not implement the complete web extension standard. See the [browser compatibliity chart](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Browser_support_for_JavaScript_APIs) for more details. +> +> _2_ - In practice, the browsers are close enough to chrome that they work 99% of the time. But make sure to test your extension before assuming it will work. diff --git a/docs/guide/contributing.md b/docs/guide/contributing.md index d012a34..99de21c 100644 --- a/docs/guide/contributing.md +++ b/docs/guide/contributing.md @@ -1,10 +1,12 @@ # Contributing -Here you'll find everything you need to know about contributing to the project. +Special thanks to the contributors. I look forward to seeing you in the list! -[[toc]] + + + -:::info First time contributing to open source? +:::details First time contributing to open source? It's easy! Here are some resources to get started: - https://www.youtube.com/embed/dSl_qnWO104 @@ -12,13 +14,11 @@ It's easy! Here are some resources to get started: ::: -## Contibutors +
-Special thanks to all the contributors. I look forward to seeing you in the list! +###### Table of Contents - - - +[[toc]] ## Project Goals @@ -27,12 +27,14 @@ The goal of `webext-core` is to create useful, targetted, quality utilities for With that in mind, there's a couple of expectations I have around new code: - Code is written in TypeScript and packages provide great TypeScript support. -- Fully unit tested. I won't require 100% coverage, but it should be close. - Utilities support all browsers. +- Well unit tested. I won't require 100% coverage, but it should be close. -If you're just fixing a bug or improving the docs, feel free to open a PR! +## Before You Contribute -If you want to create a new package, open an issue first. That way we can collaborate and make sure it fits the purpose listed above. If it's not something I want to maintain or it doesn't fit this project, I don't want you to have wasted time working on it. We all have lives to live :smiley: +If you're just fixing a bug or improving the docs, feel free to open a PR, no questions asked! + +If you want to add a new package or feature, open an issue first. That way we can collaborate and make sure it fits the purpose listed in the [project goals](#project-goals). If you open a PR, but it's not something I want to maintain or it doesn't fit this project, you will have wasted your time. We both have lives to live :smiley:. ## Development Setup @@ -44,7 +46,7 @@ You'll need to install some tools: Then you can fork the repo, install the dependencies, and build the packages for the first time! ```sh -git clone +git clone {your-fork} cd webext-core pnpm i pnpm build @@ -109,7 +111,7 @@ If you are submitting PRs, don't worry about this! A maintainer will squash and Each commit's title effects the publishing process. The style is based on conventional commits, but using scoped prefixes. Use the `fix(package-name): ` and `feat(package-name): ` prefixes when commiting a change. For example, the following commit history: -```txt +``` docs: Fixed typos fix(storage): Some change feat(proxy-service): Some new feature diff --git a/docs/fake-browser/implemented-apis.md b/docs/guide/fake-browser/implemented-apis.md similarity index 97% rename from docs/fake-browser/implemented-apis.md rename to docs/guide/fake-browser/implemented-apis.md index a5db1ac..73a8f01 100644 --- a/docs/fake-browser/implemented-apis.md +++ b/docs/guide/fake-browser/implemented-apis.md @@ -1,3 +1,9 @@ +--- +next: + text: API Reference + link: /api/fake-browser +--- + # Implmeneted APIs This file lists all the implemented APIs, their caveots, limitations, and example tests. Example tests are writen with vitest. diff --git a/docs/fake-browser/index.md b/docs/guide/fake-browser/index.md similarity index 72% rename from docs/fake-browser/index.md rename to docs/guide/fake-browser/index.md index 011c528..5a76a0a 100644 --- a/docs/fake-browser/index.md +++ b/docs/guide/fake-browser/index.md @@ -21,8 +21,8 @@ pnpm i -D @webext-core/fake-browser > This package only really works with projects using node, so only the NPM install steps are shown. -See [Testing Frameworks](/fake-browser/testing-frameworks) to setup mocks for your testing framework of choice. +See [Testing Frameworks](/guide/fake-browser/testing-frameworks) to setup mocks for your testing framework of choice. ## Examples -See [Implemented APIs](/fake-browser/implemented-apis) for example tests and details on how to use each API. +See [Implemented APIs](/guide/fake-browser/implemented-apis) for example tests and details on how to use each API. diff --git a/docs/fake-browser/reseting-state.md b/docs/guide/fake-browser/reseting-state.md similarity index 100% rename from docs/fake-browser/reseting-state.md rename to docs/guide/fake-browser/reseting-state.md diff --git a/docs/fake-browser/testing-frameworks.md b/docs/guide/fake-browser/testing-frameworks.md similarity index 100% rename from docs/fake-browser/testing-frameworks.md rename to docs/guide/fake-browser/testing-frameworks.md diff --git a/docs/fake-browser/triggering-events.md b/docs/guide/fake-browser/triggering-events.md similarity index 100% rename from docs/fake-browser/triggering-events.md rename to docs/guide/fake-browser/triggering-events.md diff --git a/docs/guide/index.md b/docs/guide/index.md index a554899..481c910 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -1,11 +1,23 @@ -# Getting Started +# Introduction ## Overview -`@webext-core`'s packages are provided via NPM. Depending on your project's setup, you can use them in different ways: +All of `@webext-core`'s packages are provided via NPM. Depending on your project's setup, you can consume them in 2 different ways: -- If your project uses a bundler like Vite or Webpack, see [Bundler Setup](#bundler-setup). -- If your project does not use a bundler, see [Vanilla Setup](#non-bundler-setup) +1. If your project uses a bundler like Vite or Webpack, see [Bundler Setup](#bundler-setup). +2. If your project does not use a bundler, see [Non-bundler Setup](#non-bundler-setup) + +## List of packages + + + + ## Bundler Setup @@ -28,7 +40,7 @@ import { localExtStorage } from '@webext-core/storage'; const value = await localExtStorage.getItem('some-key'); ``` -## Vanilla Setup +## Non-bundler Setup If you're not using a bundler, you'll have to download each package and put it inside your project. diff --git a/docs/isolated-element/index.md b/docs/guide/isolated-element/index.md similarity index 66% rename from docs/isolated-element/index.md rename to docs/guide/isolated-element/index.md index e620a20..c5a8933 100644 --- a/docs/isolated-element/index.md +++ b/docs/guide/isolated-element/index.md @@ -1,5 +1,8 @@ --- titleTemplate: '@webext-core/isolated-element' +next: + text: API Reference + link: /api/isolated-element --- # Isolated Element @@ -20,9 +23,9 @@ It will let you load UIs from content scripts without worrying about the page's ## Installation -###### Bundler +###### NPM -```ts +```sh pnpm i @webext-core/isolated-element ``` @@ -30,7 +33,7 @@ pnpm i @webext-core/isolated-element import { createIsolatedElement } from '@webext-core/isolated-element'; ``` -###### Vanilla +###### CDN ```sh curl -o isolated-element.js https://cdn.jsdelivr.net/npm/@webext-core/isolated-element/lib/index.global.js @@ -92,11 +95,3 @@ import App from './App.tsx'; ReactDOM.createRoot(isolatedElement).render(); ``` - -## Options - -| Option | Type | Required | Default | Description | -| :----- | :--------------------------------------------- | :------: | :--------: | :---------------------------------------------------------------------------------------------------------------------- | -| `name` | `string` | ✅ | | A unique tag name used when defining the web component used internally. Don't use the same name twice for different UIs | -| `css` | `{ url: string }` or `{ textContent: string }` | | | Either the URL to a CSS file or the text contents of a CSS file | -| `mode` | `"open"` or `"closed"` | | `"closed"` | See [`ShadowRoot.mode`](https://developer.mozilla.org/en-US/docs/Web/API/ShadowRoot/mode) | diff --git a/docs/guide/job-scheduler/index.md b/docs/guide/job-scheduler/index.md new file mode 100644 index 0000000..b9392d6 --- /dev/null +++ b/docs/guide/job-scheduler/index.md @@ -0,0 +1,182 @@ +--- +titleTemplate: '@webext-core/job-scheduler' +next: + text: API Reference + link: /api/job-scheduler +--- + +# Job Scheduler + + + + + + + + + +## Overview + +`@webext-core/job-scheduler` uses the [alarms API](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/alarms) to manage different types of reoccuring jobs: + +- One-time jobs +- Jobs that run on an interval +- Cron jobs + +## Installation + +###### NPM + +```sh +pnpm i @webext-core/job-scheduler +``` + +```ts +import { defineJobScheduler } from '@webext-core/job-scheduler'; +``` + +###### CDN + +```sh +curl -o job-scheduler.js https://cdn.jsdelivr.net/npm/@webext-core/job-scheduler/lib/index.global.js +``` + +```html + + +``` + +## Usage + +`defineJobSchduler` should to be executed once in the background. It returns an object that can be used to schedule or remove jobs. + +:::code-group + +```ts [background.ts] +import { defineJobScheduler } from '@webext-core/job-scheduler'; + +const jobs = defineJobScheduler(); +``` + +::: + +Once the job scheduler is created, call `scheduleJob`. To see all the options for configuring jobs, see the [API reference](/api/job-scheduler). + +:::code-group + +```ts [One time] +jobs.scheduleJob({ + id: 'job1', + type: 'once', + date: Date.now() + 1.44e7, // In 4 hours + execute: () => { + console.log('Executed job once'); + }, +}); +``` + +```ts [On an interval] +jobs.scheduleJob({ + id: 'job2', + type: 'interval', + interval: DAY, // Runs every 24 hours + execute: () => { + console.log('Executed job on interval'); + }, +}); +``` + +```ts [CRON] +jobs.scheduleJob({ + id: 'job3', + type: 'cron', + expression: '0 */2 * * *', // https://crontab.guru/#0_*/2_*_*_* + execute: () => { + console.log('Executed CRON job'); + }, +}); +``` + +::: + +If a job has been created in the past, and nothing has changed, `scheduleJob` will do nothing. If something changed, it will update the job. + +To stop running a job, call `removeJob`. + +```ts +job.removeJob('some-old-job'); +``` + +:::warning +This is especially important when releasing an update after removing a job that is no longer needed - even if `scheduleJob` isn't called anymore. If you don't call `removeJob`, the alarm managed internally for that job will not be deleted. +::: + +## Parameterized Jobs + +You can't pass parameters into each individual job execution, but you can pass dependencies when scheduling a job by using higher-order functions: + +:::code-group + +```ts [background.ts] +import { someJob } from './someJob.ts'; + +// Create your dependency +const someDependency = new SomeDependency(); + +const jobs = defineJobScheduler(); +jobs.scheduleJob({ + // ... + execute: someJob(someDependency), +}); +``` + +```ts [someJob.ts] +function someJob(someDependency: SomeDependency) { + return async () => { + // Use someDependency + }; +} +``` + +::: + +## Other JS Contexts + +You should only create one scheduler, and it should be created in the background page/service worker. + +To schedule jobs from a UI or content script, you can use [`@webext-core/proxy-service`](/guide/proxy-service/). + +:::code-group + +```ts [job-scheduler.ts] +import { defineProxyService } from '@webext-core/proxy-service'; + +export const [registerJobScheduler, getJobScheduler] = defineProxyService('JobScheduler', () => + defineJobScheduler(), +); +``` + +```ts [background.ts] +import { registerJobScheduler } from './job-scheduler'; + +const jobs = registerJobScheduler(); + +// Schedule any jobs in the background +jobs.scheduleJob({ + // ... +}); +``` + +```ts [content-script.ts] +import { getJobScheduler } from './job-scheduler'; + +// Get a proxy instance and use it to schedule more jobs +const jobs = getJobScheduler(); +jobs.scheduleJob({ + // ... +}); +``` + +::: diff --git a/docs/messaging/index.md b/docs/guide/messaging/index.md similarity index 93% rename from docs/messaging/index.md rename to docs/guide/messaging/index.md index 8fd5c9b..04bdc35 100644 --- a/docs/messaging/index.md +++ b/docs/guide/messaging/index.md @@ -16,13 +16,13 @@ titleTemplate: '@webext-core/messaging' `@webext-core/messaging` a simplified, type-safe wrapper around the web extension messaging APIs. -> Don't like lower-level messaging APIs? Try out [`@webext-core/proxy-service`](/proxy-service/) for a more DX-friendly approach to messaging. +> Don't like lower-level messaging APIs? Try out [`@webext-core/proxy-service`](/guide/proxy-service/) for a more DX-friendly approach to executing code in the background script. ## Installation -###### Bundler +###### NPM -```ts +```sh pnpm i @webext-core/messaging ``` @@ -30,7 +30,7 @@ pnpm i @webext-core/messaging import { defineExtensionMessaging } from '@webext-core/messaging'; ``` -###### Vanilla +###### CDN ```sh curl -o messaging.js https://cdn.jsdelivr.net/npm/@webext-core/messaging/lib/index.global.js @@ -97,6 +97,8 @@ console.log(length); // 11 ::: +### Sending Messages to Tabs + You can also send messages from your background script to a tab, but you need to know the `tabId`. :::code-group diff --git a/docs/messaging/protocol-maps.md b/docs/guide/messaging/protocol-maps.md similarity index 53% rename from docs/messaging/protocol-maps.md rename to docs/guide/messaging/protocol-maps.md index 6746224..8776b95 100644 --- a/docs/messaging/protocol-maps.md +++ b/docs/guide/messaging/protocol-maps.md @@ -1,3 +1,9 @@ +--- +next: + text: API Reference + link: /api/messaging +--- + # Protocol Maps > Only relevant to TypeScript projects. @@ -34,47 +40,26 @@ const res /* : boolean */ = await sendMessage('message4', 'text'); ## Async Messages -All messages are async. In your protocol map, don't specify return types as `Promise`, just use `T`. +All messages are async. In your protocol map, you don't need to make the return type `Promise`, `T` will work just fine. ```ts interface ProtocolMap { - // Do this: - someMessage(): string; - - // Not this: - someMessage(): Promise; + someMessage(): string; // [!code ++] + someMessage(): Promise; // [!code --] } ``` ## Multiple Arguments -Protocol maps only support a single argument. To pass more than one argument, pass an object instead! +Protocol map functions should be defined with a single parameter, `data`. To pass more than one argument, make the `data` parameter an object instead! ```ts interface ProtocolMap { - someMessage(data: { arg1: string; arg2: boolean }): void; - - // THIS DOES NOT WORK - the data type will be inferred as the first argument's type. - someMessage(arg1: string, arg2: boolean): void; + someMessage(data: { arg1: string; arg2: boolean }): void; // [!code ++] + someMessage(arg1: string, arg2: boolean): void; // [!code --] } ``` ```ts -await sendMessage('someMessage', { arg1: '...', arg2: true }); -``` - -## `ProtocolWithReturn` - -Instead of using function declarations, you can use the following to achieve the same types: - - -```ts -interface ProtocolMap { - message1: undefined; // No data and no return type - message2: string; // Only data - message3: ProtocolWithReturn; // Only a return type - message4: ProtocolWithReturn; // Data and return type -} +await sendMessage('someMessage', { arg1: ..., arg2: ... }); ``` - -> The functional syntax is easier to read and more intuative, and was added in `v1.3.0` to replace this syntax. diff --git a/docs/proxy-service/variants.md b/docs/guide/proxy-service/defining-services.md similarity index 94% rename from docs/proxy-service/variants.md rename to docs/guide/proxy-service/defining-services.md index f3e84c5..cd8bc63 100644 --- a/docs/proxy-service/variants.md +++ b/docs/guide/proxy-service/defining-services.md @@ -1,4 +1,10 @@ -# Variants +--- +next: + text: API Reference + link: /api/proxy-service +--- + +# Defining Services There are several different ways to define a proxy service: @@ -99,9 +105,7 @@ const todos = await getAllTodos(); ## Nested Objects -If you need to register "deep" objects containing multiple services, you can do that as well. You can use classes or objects inside the main object. - -Here I mix classes and functions, but you'll want to pick one to stay consistent. +If you need to register "deep" objects containing multiple services, you can do that as well. You can use classes, objects, and functions at any level. ```ts import { openDB, IDBPDatabase } from 'idb'; diff --git a/docs/proxy-service/index.md b/docs/guide/proxy-service/index.md similarity index 91% rename from docs/proxy-service/index.md rename to docs/guide/proxy-service/index.md index 9f2fc8a..95764b5 100644 --- a/docs/proxy-service/index.md +++ b/docs/guide/proxy-service/index.md @@ -36,7 +36,7 @@ export const [registerMathService, getMathService] = defineProxyService( ```ts [background.ts] import { registerMathService } from './MathService'; -// 2. As soon as possible in your background, register it +// 2. As soon as possible, register it in the background script registerMathService(); ``` @@ -46,7 +46,7 @@ import { getMathService } from './MathService'; // 3. Get an instance of your service anywhere in your extension const mathService = getMathService(); -// 4. Call methods like normal, but they will execute in the background +// 4. Call methods like normal, they will execute in the background await mathService.fibonacci(100); ``` @@ -54,9 +54,9 @@ await mathService.fibonacci(100); ## Installation -###### Bundler +###### NPM -```ts +```sh pnpm i @webext-core/proxy-service ``` @@ -64,7 +64,7 @@ pnpm i @webext-core/proxy-service import { defineProxyService } from '@webext-core/proxy-service'; ``` -###### Vanilla +###### CDN ```sh curl -o proxy-service.js https://cdn.jsdelivr.net/npm/@webext-core/proxy-service/lib/index.global.js @@ -114,7 +114,7 @@ function createTodosRepo(idbPromise: Promise) { ::: -> In this example, we're using a plain object instead of a class as the service. See the [Variants](./variants) docs for more variations in creating proxy services. +> In this example, we're using a plain object instead of a class as the service. See the [Defining Services](./defining-services) docs for examples of all the different ways to create a proxy service. In the same file, define a proxy service for our `TodosRepo`: @@ -150,7 +150,7 @@ Here, even though `openDB` returns a promise, we're not awaiting the promise unt You can follow the pattern of passing `Promise` into your services and awaiting them internally to stay synchronous. -[`flattenPromise`](./flatten-promise) is used to make consuming this promise easier. +[`flattenPromise`](/api/proxy-service#flattenpromise) is used to make consuming this promise easier. ::: And that's it. You can now access your IndexedDB database from any JS context inside your extension: diff --git a/docs/guide/storage/index.md b/docs/guide/storage/index.md new file mode 100644 index 0000000..a88ce4f --- /dev/null +++ b/docs/guide/storage/index.md @@ -0,0 +1,69 @@ +--- +titleTemplate: '@webext-core/storage' +--- + +# Storage + + + + + + + + + +## Overview + +`@webext-core/storage` provides a type-safe, `localStorage`-like API for interacting with extension storage. + +```ts +const { key: value } = await browser.storage.local.get('key'); +// VS +const value = await localExtStorage.getItem('key'); +``` + +:::warning +Requires the `storage` permission. +::: + +## Installation + +###### NPM + +```sh +pnpm i @webext-core/storage +``` + +```ts +import { localExtStorage } from '@webext-core/storage'; + +const value = await localExtStorage.getItem('key'); +await localExtStorage.setItem('key', 123); +``` + +###### CDN + +```sh +curl -o storage.js https://cdn.jsdelivr.net/npm/@webext-core/storage/lib/index.global.js +``` + +```html + + +``` + +## Differences with `localStorage` and `browser.storage` + +| | @webext-core/storage | `localStorage` | `browser.storage` | +| ---------------------------------------- | :-----------------------------------------------------------: | :------------: | :---------------: | +| **Set value to `undefined` removes it?** | ✅ | ✅ | ❌ | +| **Returns `null` for missing values?** | ✅ | ✅ | ❌ | +| **Can store values of any type?** | ✅ | ❌ | ✅ | +| **Async?** | ✅ | ❌ | ✅ | + +Otherwise, the storage behaves the same as `localStorage` / `sessionStorage`. diff --git a/docs/storage/typescript.md b/docs/guide/storage/typescript.md similarity index 52% rename from docs/storage/typescript.md rename to docs/guide/storage/typescript.md index e7159ec..66ece80 100644 --- a/docs/storage/typescript.md +++ b/docs/guide/storage/typescript.md @@ -1,46 +1,54 @@ +--- +next: + text: API Reference + link: /api/storage +--- + # TypeScript ## Adding Type Safety -If your project uses TypeScript, you can make your storage type-safe by passing a schema into the first type parameter of `defineExtensionStorage`. +If your project uses TypeScript, you can make your own type-safe storage by passing a schema into the first type argument of `defineExtensionStorage`. ```ts import { defineExtensionStorage } from '@webext-core/storage'; import browser from 'webextension-polyfill'; -export interface LocalExtStorageSchema { +export interface ExtensionStorageSchema { installDate: number; notificationsEnabled: boolean; favoriteUrls: string[]; } -export const localExtStorage = defineExtensionStorage(browser.storage.local); +export const extensionStorage = defineExtensionStorage( + browser.storage.local, +); ``` -Then, when you use this `localExtStorage`, not the one exported from the package, you'll get type errors when using keys not in the schema: +Then, when you use this `extensionStorage`, not the one exported from the package, you'll get type errors when using keys not in the schema: ```ts -localExtStorage.getItem('unknownKey'); -// ~~~~~~~~~~~~ Error: 'unknownKey' does not match `keyof LocalExtStorageSchema` +extensionStorage.getItem('unknownKey'); +// ~~~~~~~~~~~~ Error: 'unknownKey' does not match `keyof LocalExtStorageSchema` -const installDate: Date = await localExtStorage.getItem('installDate'); +const installDate: Date = await extensionStorage.getItem('installDate'); // ~~~~~~~~~~~~~~~~~ Error: value of type 'number' cannot be assigned to type 'Date' -await localExtStorage.setItem('favoriteUrls', 'not-an-array'); -// ~~~~~~~~~~~~~~ Error: type 'string' is not assignable to 'string[]' +await extensionStorage.setItem('favoriteUrls', 'not-an-array'); +// ~~~~~~~~~~~~~~ Error: type 'string' is not assignable to 'string[]' ``` -When used correctly, types will be automatically inferred: +When used correctly, types will be automatically inferred without having to specify the type anywhere: ```ts -const installDate /*: number | null */ = await localExtStorage.getItem('installDate'); -await localExtStorage.setItem('installDate', 123); +const installDate /*: number | null */ = await extensionStorage.getItem('installDate'); +await extensionStorage.setItem('installDate', 123); -const notificationsEnalbed /*: boolean | null */ = await localExtStorage.getItem( +const notificationsEnalbed /*: boolean | null */ = await extensionStorage.getItem( 'notificationsEnalbed', ); -const favorites /*: string[] | null */ = await localExtStorage.getItem('favoriteUrls'); +const favorites /*: string[] | null */ = await extensionStorage.getItem('favoriteUrls'); favorites ??= []; favorites.push('https://github.com'); await localExtSTorage.setItem('favoriteUrls', favorites); @@ -48,7 +56,7 @@ await localExtSTorage.setItem('favoriteUrls', favorites); ## Hanlding `null` Correctly -When using a schema, you'll notice that `getItem` returns `T | null`, but `setItem` requires the value being set to not be null. +When using a schema, you'll notice that `getItem` returns `T | null`, but `setItem` requires a non-null value. By default, getting items from storage could always return `null` if a value hasn't been set. But if you type the schema as required fields, you're only be allowed to set non-null values. @@ -63,7 +71,7 @@ export interface LocalExtStorageSchema { } ``` -## Never Use `undefined` +### Never Use `undefined` Missing storage values will always be returned as `null`, never as `undefined`. So you shouldn't use `?:` or `| undefined` since that doesn't represent the actual type of your values. diff --git a/docs/index.md b/docs/index.md index 3c149c9..4092eb0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -6,7 +6,7 @@ titleTemplate: Web Extensions Made Easy hero: name: Web Ext Core - tagline: Core libraries for developing web extensions on all browsers. + tagline: Core libraries to develop web extensions for all browsers. image: src: /logo-with-shadow.png alt: Vite @@ -21,25 +21,32 @@ hero: features: - icon: 📦 title: '@webext-core/storage' - link: /storage/ + link: /guide/storage/ details: An alternative, type-safe API similar to local storage for accessing extension storage. - icon: 💬 title: '@webext-core/messaging' - link: /messaging/ + link: /guide/messaging/ details: A simpler, type-safe API for sending and recieving messages. + - icon: 👷 + title: '@webext-core/job-scheduler' + link: /guide/job-scheduler/ + details: Easily schedule and manage reoccuring jobs. - icon: 🚍 title: '@webext-core/proxy-service' - link: /proxy-service/ + link: /guide/proxy-service/ details: Call a function, but execute in a different JS context, like the background. - icon: 🧩 title: '@webext-core/isolated-element' - link: /isolated-element/ + link: /guide/isolated-element/ details: Create a container who's styles are isolated from the page's styles. - icon: 🛠️ title: '@webext-core/fake-browser' - link: /fake-browser/ + link: /guide/fake-browser/ details: An in-memory implementation of webextension-polyfill for testing. - icon: 🚀 title: 'COMING SOON: @webext-core/publish' - details: Publish your extension to the various stores. + details: Publish your extension to the various stores, with full support for Firefox source code uploads. + link: https://github.com/aklinker1/publish-browser-extension + - {} + - {} --- diff --git a/docs/messaging/api.md b/docs/messaging/api.md deleted file mode 100644 index 8c4cbc2..0000000 --- a/docs/messaging/api.md +++ /dev/null @@ -1,83 +0,0 @@ -# API - -## `defineExtensionMessaging` - -```ts -// Type -(options: ExtensionMessagingConfig) => { - sendMessage: SendMessage; - onMessage: OnMessage; -}; -``` - -Returns [`sendMessage`](#sendmessage) and [`onMessage`](#onmessage) with types based on your [`TProtocolMap`](#protocolmap). - -- `options` [`ExtensionMessagingConfig`](#extensionmessagingconfig): Configures the behavior of `sendMessage` and `onMessage`. - -## `sendMessage` - -```ts -// Type -(type: TType, data: GetData, tabId?: number) => - Promise>; -``` - -Send a message to the background script, or if a `tabId` is passed, to the requested tab. - -Wherever you're sending the message to, it must have called `onMessage` with the same message type to recieve it, or you'll get an error. - -## `onMessage` - -```ts -// Type -( - type: TType, - onRecieved: ( - message: Message>, - ) => MaybePromise>, -) => RemoveOnMessage; -``` - -Add a listener that calls the `onRecieved` callback function when a message of the same `type` is recieved. - -Returns a function that when called, removes the listener. - -The [`Message`](#message) object contains details about the message that was sent. - -If the message type requires a response, you can return a value syncronously or return a Promise of the return type. - -## `Message` - -```ts -// Type -interface Message { - id: number; - data: GetData; - sender: browser.Runtime.MessageSender; - timestamp: number; -} -``` - -Contains details about the message recieved by the listener. - -- `id`: A auto-incrementing semi-unique identifier for the message. Useful for tracing message chains in debug mode. -- `data`: The data sent with the message, or `undefined` if there is no data. -- `sender`: Details about where the message was sent from. See [`Runtime.MessageSender`](https://developer.chrome.com/docs/extensions/reference/runtime/#type-MessageSender) for more details. -- `timestamp`: The MS since epoch when the message was sent. - -## `ProtocolWithReturn` - -A utility type for defining a message with a response. - -See [Protocol Maps](/messaging/protocol-maps) for more details. - -## `ExtensionMessagingConfig` - -```ts -// Type -interface ExtensionMessagingConfig { - logger?: object | null; -} -``` - -- `logger` (_default: `console`_): custom logger for printing sent and recieved messages to the console. Pass `null` to disable logging. diff --git a/docs/proxy-service/api.md b/docs/proxy-service/api.md deleted file mode 100644 index 8b8c395..0000000 --- a/docs/proxy-service/api.md +++ /dev/null @@ -1,104 +0,0 @@ -# API - -## `defineProxyService` - -```ts -// Type -export function defineProxyService( - name: string, - init: (...args: TArgs) => TService, - config?: ProxyServiceConfig, -): [registerService: (...args: TArgs) => TService, getService: () => ProxyService]; -``` - -You service can also be defined as any of the following: - -- Single function -- Class definition -- Plain object -- Deeply nested object containing all the above - -See [Variants](./variants) for examples of using all the different types of services. - -### Parameters - -- `name: string`: A unique name for the service. Used to identify which service is being executed. -- `init: (...args: TArgs) => TService`: A function that returns your real service implementation. If args are listed, `registerService` will require the same set of arguments -- `config?:` [`ProxyServiceConfig`](#proxyserviceconfig): Optional configuration options. - -### Returns - -Returns a tuple of two functions, `registerService` and `getService`: - -- `registerService`: Registers the real implmenetation of the service in the background script -- `getService`: Returns the registered service when called from the background, or a [`ProxyService`](#proxyservice) when called from anywhere else. - -The [`ProxyService`](#proxyservice) uses [`@webext-core/messenger`](/messaging/) to forward messages to the background where the registered, real implementation executes the correct function. - -### Example - -```ts -import { defineProxyService } from '@webext-core/storage'; - -export const [registerService, getService] = defineProxyService( - 'TodosRepo', - (idb: Promise) => ({ - async getTodo(id: string) { - return (await idb).get('todos', id); - }, - }), -); -``` - -## `ProxyService` - -```ts -// Type -type ProxyService = DeepAsync extends T ? T : DeepAsync; -``` - -Because of the async nature of messaging, all functions on [`ProxyService`](#proxyservice)'s are async, but your real implemenatation does not have to be async. - -## `DeepAsync` - -```ts -// Type -type DeepAsync = TService extends (...args: any) => any - ? ToAsyncFunction - : TService extends { [key: string]: any } - ? { - [fn in keyof TService]: DeepAsync; - } - : never; -``` - -Converts function to async functions and object's with functions to objects with async functions. - -All other types of values are converted to `never` - -### Example - -```ts -interface SomeService { - name: string; - syncFn(): number; - asyncFn(): Promise; - nested: { - name: string; - syncFn(): number; - asyncFn(): Promise; - }; -} - -type AsyncSomeService = DeepAsync; -// type AsyncSomeService = { -// name: never; -// syncFn(): Promise; -// asyncFn(): Promise; -// nested: { -// name: never; -// syncFn(): Promise; -// asyncFn(): Promise; -// }; -// } -``` diff --git a/docs/storage/api.md b/docs/storage/api.md deleted file mode 100644 index 113b6cd..0000000 --- a/docs/storage/api.md +++ /dev/null @@ -1,183 +0,0 @@ -# API - -## `localExtStorage` - -```ts -// Type -ExtensionStorage; -``` - -A [`ExtensionStorage`](#extensionstorage) implementation backed by the `browser.storage.local` extension storage. - -No schema is applied: you can use any string for storage keys, and the values are always `any`. - -:::info -See [`defineExtensionStorage`](#defineextensionstorage) for creating a type-safe [`ExtensionStorage`](#extensionstorage) implementation. -::: - -### Example - -```ts -import { localExtStorage } from '@webext-core/storage'; - -await localExtStorage.setItem('any-key', 'any-value'); -``` - -## `syncExtStorage` - -```ts -// Type -ExtensionStorage; -``` - -A [`ExtensionStorage`](#extensionstorage) implementation backed by the `browser.storage.sync` extension storage. - -No schema is applied: you can use any string for storage keys, and the values are always `any`. - -:::info -See [`defineExtensionStorage`](#defineextensionstorage) for creating a type-safe [`ExtensionStorage`](#extensionstorage) implementation. -::: - -### Example - -```ts -import { syncExtStorage } from '@webext-core/storage'; - -await syncExtStorage.setItem('any-key', 'any-value'); -``` - -## `managedExtStorage` - -```ts -// Type -ExtensionStorage; -``` - -A [`ExtensionStorage`](#extensionstorage) implementation backed by the `browser.storage.managed` extension storage. - -No schema is applied: you can use any string for storage keys, and the values are always `any`. - -:::info -See [`defineExtensionStorage`](#defineextensionstorage) for creating a type-safe [`ExtensionStorage`](#extensionstorage) implementation. -::: - -### Example - -```ts -import { managedExtStorage } from '@webext-core/storage'; - -await managedExtStorage.setItem('any-key', 'any-value'); -``` - -## `defineExtensionStorage` - -```ts -// Type -(storage: browser.Storage.StorageArea) => ExtensionStorage; -``` - -Returns a [`ExtensionStorage`](#extensionstorage) implementation backed by the `storage` parameter. - -Accepts an optional type parameter, `TSchema`, to make the storage type-safe. See [`Typescript`](/storage/typescript) for more details on creating type-safe storages. - -### Example - -```ts -import { defineExtensionStorage } from '@webext-core/storage'; - -export const localExtStorage = defineExtensionStorage(browser.storage.local); -``` - -## `ExtensionStorage` - -```ts -// Type -interface ExtensionStorage { - clear(): Promise; - getItem(key: TKey): Promise[TKey] | null>; - setItem(key: TKey, value: TSchema[TKey]): Promise; - removeItem(key: TKey): Promise; - onChange(key: TKey, cb: OnChangeCallback): RemoveListenerCallback; -} -``` - -This is the interface for the storage objects exported from the package. It is similar to `localStorage`, except for a few differences: - -1. It's async since the extension storage APIs are all async -2. It stores and returns any type of value, not just `string`s - -### `clear` - -```ts -// Type -ExtensionStorage.clear(): Promise -``` - -Removes all stored values from the storage. - -### `getItem` - -```ts -// Type -ExtensionStorage.getItem(key: TKey): Promise[TKey] | null>; -``` - -Gets an item from storage. Returns `null` when the key is missing from storage, never `undefined`. - -### `setItem` - -```ts -// Type -ExtensionStorage.setItem(key: TKey, value: TSchema[TKey]): Promise; -``` - -Sets a key/value pair in storage. - -:::warning Passing `undefined` for the value will result in a noop! -To remove an individual item, pass `null` for the value or call `removeItem` instead. -::: - -### `removeItem` - -```ts -// Type -ExtensionStorage.removeItem(key: TKey): Promise; -``` - -Deletes an key from storage. - -### `onChange` - -```ts -// Type -ExtensionStorage.onChange(key: TKey, cb: OnChangeCallback): RemoveListenerCallback; -``` - -Adds a listener that is called when the value in storage for the specified key is changed (`newValue !== oldValue`). - -To remove the listener, call the function that is returned. - -#### Example - -```ts -import { localExtStorage } from '@webext-core/storage'; - -// Setup a listener for the "key1" key -const removeListener = localExtStorage.onChange('key1', (newValue, oldValue) => { - console.log('Changed: ', { newValue, oldValue }); -}); - -await localExtStorage.setItem('key1', 'abc'); -// log: "Changed: { newValue: 'abc', oldValue: 'null' }" - -await localExtStorage.setItem('key2', '123'); -// nothing logged - different key changed - -await localExtStorage.setItem('key1', 'abc'); -// nothing logged - same value as before - -removeListener(); - -await localExtStorage.setItem('key1', 'def'); -// nothing logged - listener was removed -``` diff --git a/docs/storage/index.md b/docs/storage/index.md deleted file mode 100644 index e77a852..0000000 --- a/docs/storage/index.md +++ /dev/null @@ -1,144 +0,0 @@ ---- -titleTemplate: '@webext-core/storage' ---- - -# Storage - - - - - - - - - -## Overview - -`@webext-core/storage` provides a localStorage-like API for interacting with extension storage. - -```ts -const { key: value } = await browser.storage.local.get('key'); -// VS -const value = await localExtStorage.getItem('key'); -``` - -:::warning -Requires the `storage` permission. -::: - -## Installation - -###### Bundler - -```ts -pnpm i @webext-core/storage -``` - -```ts -import { localExtStorage } from '@webext-core/storage'; - -const value = await localExtStorage.getItem('key'); -await localExtStorage.setItem('key', 123); -``` - -###### Vanilla - -```sh -curl -o storage.js https://cdn.jsdelivr.net/npm/@webext-core/storage/lib/index.global.js -``` - -```html - - -``` - -## Differences with `localStorage` and `browser.storage` - -### It's async - -- `localStorage` uses _synchronous_ APIs -- `browser.storage` uses _asynchronous_ APIs -- `@webext-core/storage` uses _asynchronous_ APIs, **same as `browser.storage`** - -:::code-group - -```ts [localStorage] -localStorage.getItem('key'); -``` - -```ts [browser.storage] -await browser.storage.local.get('key'); -``` - -```ts [@webext-core/storage] -await localExtStorage.getItem('key'); -``` - -::: - -### Values are stored as-is - -- `localStorage` can only save strings. You have to manually convert values to and from strings. -- `browser.storage` stores values without having to convert to and from a string. -- `@webext-core/storage` stores values without having to convert to and from a string, **same as `browser.storage`** - -:::code-group - -```ts [localStorage] -localStorage.setItem('key', JSON.stringify({ property1: false, property2: 1 })); -const itemStr = localStorage.getItem('key'); -const item = itemStr == null ? null : JSON.parse(itemStr); -``` - -```ts [browser.storage] -await browser.storage.local.set({ - key: { property1: false, property2: 1 }, -})); -const { key: item } = await browser.storage.local.get('key'); -``` - -```ts [@webext-core/storage] -await localExtStorage.setItem('key', { property1: false, property2: 1 }); -const item = await localExtStorage.getItem('key'); -``` - -::: - -### Setting key to `undefined` - -- `localStorage` will return `null` after setting a key to `undefined` -- `browser.storage` will return the previous value after setting a key to `undefined`, `undefined` values are ignored. -- `@webext-core/storage` will return `null` after setting a key to `undefined`, **same as `localStorage`**. - -:::code-group - -```ts [Web Extension Storage] -await browser.storage.local.set({ key: 'some-value' }); -await browser.storage.local.set({ key: undefined }); -const { key: value } = await browser.storage.local.get('key'); - -console.log(value); // "some-value" -``` - -```ts [localStorage] -localStorage.setItem('key', 'some-value'); -localStorage.setItem('key', undefined); -const value = localStorage.getItem('key'); - -console.log(value); // null -``` - -```ts [@webext-core/storage] -await localExtStorage.setItem('key', 'some-value'); -await localExtStorage.setItem('key', undefined); -const value = await localExtStorage.getItem('key'); - -console.log(value); // null -``` - -::: diff --git a/package.json b/package.json index 970b4c1..94c1318 100644 --- a/package.json +++ b/package.json @@ -13,14 +13,22 @@ }, "devDependencies": { "@aklinker1/generate-changelog": "*", + "@algolia/client-search": "^4.17.0", + "@types/prettier": "^2.7.2", "@types/webextension-polyfill": "^0.9.1", "@webext-core/fake-browser": "workspace:*", + "chokidar": "^3.5.3", "husky": "^8.0.1", + "listr2": "^6.4.2", "prettier": "^2.7.1", "pretty-quick": "^3.1.3", + "ts-morph": "^16.0.0", "turbo": "^1.6.3", - "vitepress": "1.0.0-alpha.49", + "vitepress": "1.0.0-alpha.75", "vitest": "^0.29.2", "webextension-polyfill": "^0.10.0" + }, + "dependencies": { + "linkedom": "^0.14.26" } } diff --git a/packages/fake-browser/README.md b/packages/fake-browser/README.md index 13b9999..cf97155 100644 --- a/packages/fake-browser/README.md +++ b/packages/fake-browser/README.md @@ -8,4 +8,4 @@ pnpm i -D @webext-core/fake-browser ## Get Started -See [documentation](https://webext-core.aklinker1.io/messaging) to get started! +See [documentation](https://webext-core.aklinker1.io/guide/fake-browser/) to get started! diff --git a/packages/fake-browser/src/apis/alarms.test.ts b/packages/fake-browser/src/apis/alarms.test.ts index 1a50474..ede044f 100644 --- a/packages/fake-browser/src/apis/alarms.test.ts +++ b/packages/fake-browser/src/apis/alarms.test.ts @@ -18,7 +18,7 @@ describe('Fake Alarms API', () => { expect(alarm).toEqual({ name: '', periodInMinutes: 5, - scheduledTime: now + 1000, + scheduledTime: now + 60e3, }); }); @@ -32,13 +32,17 @@ describe('Fake Alarms API', () => { }); }); - it('should not allow creating an alarm with the same name', async () => { + it('should replace an existing alarm with the same name', async () => { const name = '1'; - fakeBrowser.alarms.create(name, {}); + fakeBrowser.alarms.create(name, { when: 1 }); + fakeBrowser.alarms.create(name, { when: 2 }); + + const alarm = await fakeBrowser.alarms.get(name); - expect(() => fakeBrowser.alarms.create(name, {})).toThrow( - `Alarm named "${name}" already exists`, - ); + expect(alarm).toEqual({ + name, + scheduledTime: 2, + }); }); it('should allow creating a named alarm', async () => { @@ -52,7 +56,7 @@ describe('Fake Alarms API', () => { expect(alarm).toEqual({ name, periodInMinutes: 10, - scheduledTime: now + 2000, + scheduledTime: now + 2 * 60e3, }); }); diff --git a/packages/fake-browser/src/apis/alarms.ts b/packages/fake-browser/src/apis/alarms.ts index 14d1f7e..47ae3b5 100644 --- a/packages/fake-browser/src/apis/alarms.ts +++ b/packages/fake-browser/src/apis/alarms.ts @@ -38,12 +38,12 @@ export const alarms: BrowserOverrides['alarms'] = { name = arg0 ?? ''; alarmInfo = arg1 as Alarms.CreateAlarmInfoType; } - const existing = alarmList.find(alarm => alarm.name === name); - if (existing) throw Error(`Alarm named "${name}" already exists`); + const i = alarmList.findIndex(alarm => alarm.name === name); + if (i >= 0) alarmList.splice(i, 1); alarmList.push({ name, - scheduledTime: alarmInfo.when ?? Date.now() + (alarmInfo.delayInMinutes ?? 0) * 1000, + scheduledTime: alarmInfo.when ?? Date.now() + (alarmInfo.delayInMinutes ?? 0) * 60e3, periodInMinutes: alarmInfo.periodInMinutes, }); }, diff --git a/packages/fake-browser/src/index.ts b/packages/fake-browser/src/index.ts index 7c5f543..ae0b989 100644 --- a/packages/fake-browser/src/index.ts +++ b/packages/fake-browser/src/index.ts @@ -28,4 +28,7 @@ const overrides: BrowserOverrides = { windows, }; +/** + * An in-memory implementation of the `browser` global. + */ export const fakeBrowser: FakeBrowser = merge(GeneratedBrowser, overrides); diff --git a/packages/fake-browser/src/types.ts b/packages/fake-browser/src/types.ts index 7a0f04c..130116a 100644 --- a/packages/fake-browser/src/types.ts +++ b/packages/fake-browser/src/types.ts @@ -106,4 +106,7 @@ export interface BrowserOverrides { }; } +/** + * The standard `Browser` interface from `webextension-polyfill`, but with additional functions for triggering events and reseting state. + */ export type FakeBrowser = BrowserOverrides & Browser; diff --git a/packages/isolated-element/README.md b/packages/isolated-element/README.md index cc77776..e8379e7 100644 --- a/packages/isolated-element/README.md +++ b/packages/isolated-element/README.md @@ -29,4 +29,4 @@ document.body.appendChild(parentElement); ## Get Started -See [documentation](https://webext-core.aklinker1.io/isolated-element) to get started! +See [documentation](https://webext-core.aklinker1.io/guide/isolated-element/) to get started! diff --git a/packages/isolated-element/src/index.ts b/packages/isolated-element/src/index.ts index 07898fb..0c82a81 100644 --- a/packages/isolated-element/src/index.ts +++ b/packages/isolated-element/src/index.ts @@ -3,6 +3,28 @@ import '@webcomponents/webcomponentsjs'; export type { CreateIsolatedElementOptions }; +/** + * Create an HTML element that has isolated styles from the rest of the page. + * @param options + * @returns + * - A `parentElement` that can be added to the DOM + * - The `shadow` root + * - An `isolatedElement` that you should mount your UI to. + * + * @example + * const { isolatedElement, parentElement } = createIsolatedElement({ + * name: 'example-ui', + * css: { textContent: "p { color: red }" }, + * }); + * + * // Create and mount your app inside the isolation + * const ui = document.createElement("p"); + * ui.textContent = "Example UI"; + * isolatedElement.appendChild(ui); + * + * // Add the UI to the DOM + * document.body.appendChild(parentElement); + */ export async function createIsolatedElement(options: CreateIsolatedElementOptions): Promise<{ parentElement: HTMLElement; isolatedElement: HTMLElement; diff --git a/packages/isolated-element/src/options.ts b/packages/isolated-element/src/options.ts index 6a63013..feac582 100644 --- a/packages/isolated-element/src/options.ts +++ b/packages/isolated-element/src/options.ts @@ -1,5 +1,19 @@ +/** + * Options that can be passed into `createIsolatedElement`. + */ export interface CreateIsolatedElementOptions { + /** + * A unique tag name used when defining the web component used internally. Don't use the same name twice for different UIs. + */ name: string; + /** + * See [`ShadowRoot.mode`](https://developer.mozilla.org/en-US/docs/Web/API/ShadowRoot/mode). + * + * @default 'closed' + */ mode?: 'open' | 'closed'; + /** + * Either the URL to a CSS file or the text contents of a CSS file. The styles will be mounted inside the shadow DOM so they don't effect the rest of the page. + */ css?: { url: string } | { textContent: string }; } diff --git a/packages/job-scheduler/README.md b/packages/job-scheduler/README.md new file mode 100644 index 0000000..03f01ff --- /dev/null +++ b/packages/job-scheduler/README.md @@ -0,0 +1,9 @@ +# `@webext-core/job-scheduler` + +Simple job scheduler for web extension background scripts. + +```bash +pnpm i @webext-core/job-scheduler +``` + +See [documentation](https://webext-core.aklinker1.io/guide/job-scheduler/) to get started! diff --git a/packages/job-scheduler/package.json b/packages/job-scheduler/package.json new file mode 100644 index 0000000..5ebb8c5 --- /dev/null +++ b/packages/job-scheduler/package.json @@ -0,0 +1,65 @@ +{ + "name": "@webext-core/job-scheduler", + "version": "1.0.0", + "description": "Schedule and run jobs in your background script", + "license": "MIT", + "keywords": [ + "web-extension", + "browser-extension", + "chrome-extension", + "webext", + "web-ext", + "chrome", + "firefox", + "safari", + "browser", + "extension", + "job", + "scheduler", + "cron", + "period", + "periodic" + ], + "homepage": "https://github.com/aklinker1/webext-core/tree/main/packages/job-scheduler", + "repository": { + "type": "git", + "url": "https://github.com/aklinker1/webext-core", + "directory": "packages/job-scheduler" + }, + "type": "module", + "publishConfig": { + "access": "public" + }, + "files": [ + "lib" + ], + "main": "lib/index.cjs", + "module": "lib/index.js", + "types": "lib/index.d.ts", + "exports": { + ".": { + "import": "./lib/index.js", + "require": "./lib/index.cjs" + } + }, + "scripts": { + "build": "tsup src/index.ts --clean --out-dir lib --dts --format esm,cjs,iife --global-name webExtCoreJobScheduler", + "build:dependencies": "cd ../.. && turbo run build --filter=@webext-core/job-scheduler^...", + "test": "vitest -r src", + "test:coverage": "vitest run -r src --coverage", + "compile": "tsc --noEmit" + }, + "dependencies": { + "cron-parser": "^4.8.1", + "webextension-polyfill": "^0.10.0", + "format-duration": "^3.0.2" + }, + "devDependencies": { + "@vitest/coverage-c8": "^0.24.5", + "@webext-core/fake-browser": "workspace:*", + "tsconfig": "workspace:*", + "tsup": "^6.4.0", + "typescript": "^4.8.4", + "vitest": "^0.24.5" + } +} diff --git a/packages/job-scheduler/src/__mocks__/webextension-polyfill.ts b/packages/job-scheduler/src/__mocks__/webextension-polyfill.ts new file mode 100644 index 0000000..e517d2f --- /dev/null +++ b/packages/job-scheduler/src/__mocks__/webextension-polyfill.ts @@ -0,0 +1 @@ +export { fakeBrowser as default } from '@webext-core/fake-browser'; diff --git a/packages/job-scheduler/src/index.test.ts b/packages/job-scheduler/src/index.test.ts new file mode 100644 index 0000000..ab46a39 --- /dev/null +++ b/packages/job-scheduler/src/index.test.ts @@ -0,0 +1,286 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { defineJobScheduler } from './index'; +import { fakeBrowser } from '@webext-core/fake-browser'; +import { Alarms } from 'webextension-polyfill'; + +vi.mock('webextension-polyfill'); + +describe('defineJobScheduler', () => { + beforeEach(() => { + fakeBrowser.reset(); + vi.setSystemTime('2023-01-30T11:30:49.000Z'); + }); + + describe('scheduleJob', () => { + describe('OnceJob', () => { + it('should schedule the job correctly', async () => { + const job = { + id: 'once', + type: 'once' as const, + date: Date.now() + 60e3, + execute: vi.fn(), + }; + const expected: Alarms.Alarm = { + name: job.id, + scheduledTime: job.date, + }; + const jobs = defineJobScheduler({ logger: null }); + await jobs.scheduleJob(job); + + const alarm = await fakeBrowser.alarms.get(job.id); + await fakeBrowser.alarms.onAlarm.trigger(alarm); + + expect(job.execute).toBeCalledTimes(1); + expect(alarm).toEqual(expected); + }); + + it('should not schedule a job in the past', async () => { + const job = { + id: 'once', + type: 'once' as const, + date: Date.now() - 1, + execute: vi.fn(), + }; + const jobs = defineJobScheduler({ logger: null }); + await jobs.scheduleJob(job); + + const alarm = await fakeBrowser.alarms.get(job.id); + await fakeBrowser.alarms.onAlarm.trigger({ name: job.id, scheduledTime: Math.random() }); + + expect(job.execute).not.toBeCalled(); + expect(alarm).toBeUndefined(); + }); + }); + + describe('IntervalJob', () => { + it('should schedule the job immediately', async () => { + const minutes = 2; + const job = { + id: 'interval', + type: 'interval' as const, + duration: minutes * 60e3, + immediate: true, + execute: vi.fn(), + }; + const expected: Alarms.Alarm = { + name: job.id, + scheduledTime: Date.now(), + periodInMinutes: minutes, + }; + const jobs = defineJobScheduler({ logger: null }); + await jobs.scheduleJob(job); + + const alarm = await fakeBrowser.alarms.get(job.id); + await fakeBrowser.alarms.onAlarm.trigger(alarm); + + expect(job.execute).toBeCalledTimes(1); + expect(alarm).toEqual(expected); + }); + + it('should not schedule the job immediately if the alarm already exists', async () => { + const minutes = 2; + const job = { + id: 'interval', + type: 'interval' as const, + duration: minutes * 60e3, + immediate: true, + execute: vi.fn(), + }; + const jobs = defineJobScheduler({ logger: null }); + fakeBrowser.alarms.create(job.id, { + periodInMinutes: minutes, + delayInMinutes: minutes, + }); + const expected = await fakeBrowser.alarms.get(job.id); + + await jobs.scheduleJob(job); + + const alarm = await fakeBrowser.alarms.get(job.id); + await fakeBrowser.alarms.onAlarm.trigger(alarm); + + expect(job.execute).toBeCalledTimes(1); + expect(alarm).toEqual(expected); + }); + + it.each([undefined, false])( + 'should schedule the job after the interval when immediate=%s', + async immediate => { + const minutes = 2; + const job = { + id: 'interval', + type: 'interval' as const, + duration: minutes * 60e3, + immediate, + execute: vi.fn(), + }; + const expected: Alarms.Alarm = { + name: job.id, + scheduledTime: Date.now() + job.duration, + periodInMinutes: minutes, + }; + const jobs = defineJobScheduler({ logger: null }); + await jobs.scheduleJob(job); + + const alarm = await fakeBrowser.alarms.get(job.id); + await fakeBrowser.alarms.onAlarm.trigger(alarm); + + expect(job.execute).toBeCalledTimes(1); + expect(alarm).toEqual(expected); + }, + ); + }); + + describe('CronJob', () => { + it('should schedule a CronJob', async () => { + const job = { + id: 'cron', + type: 'cron' as const, + expression: '0 0/2 * * *', // every 2 hours on the 0th minute of the hour + execute: vi.fn(), + }; + const expected: Alarms.Alarm = { + name: job.id, + scheduledTime: new Date('2023-01-30T12:00:00.000Z').getTime(), + }; + + const jobs = defineJobScheduler({ logger: null }); + await jobs.scheduleJob(job); + + const alarm = await fakeBrowser.alarms.get(job.id); + await fakeBrowser.alarms.onAlarm.trigger(alarm); + + expect(alarm).toEqual(expected); + expect(job.execute).toBeCalledTimes(1); + }); + + it('should not schedule a CronJob with no next interval', async () => { + const job = { + id: 'cron', + type: 'cron' as const, + expression: '0 */2 * * *', + endDate: Date.now() - 1, + execute: vi.fn(), + }; + const jobs = defineJobScheduler({ logger: null }); + await jobs.scheduleJob(job); + + const alarm = await fakeBrowser.alarms.get(job.id); + await fakeBrowser.alarms.onAlarm.trigger({ name: job.id, scheduledTime: Math.random() }); + + expect(job.execute).not.toBeCalled(); + expect(alarm).toBeUndefined(); + }); + + it.each([ + ['even if it fails', vi.fn().mockRejectedValue('Some error')], + ['if it finishes without an error', vi.fn().mockResolvedValue(undefined)], + ])('should schedule the next CronJob alarm %s', async (_, execute) => { + const job = { + id: 'cron', + type: 'cron' as const, + expression: '0 0/2 * * *', // every 2 hours on the 0th minute of the hour + execute, + }; + const expected1: Alarms.Alarm = { + name: job.id, + scheduledTime: new Date('2023-01-30T12:00:00.000Z').getTime(), + }; + const expected2: Alarms.Alarm = { + name: job.id, + scheduledTime: new Date('2023-01-30T14:00:00.000Z').getTime(), + }; + + const jobs = defineJobScheduler({ logger: null }); + await jobs.scheduleJob(job); + + const alarm1 = await fakeBrowser.alarms.get(job.id); + vi.setSystemTime(alarm1.scheduledTime + 1); + await fakeBrowser.alarms.onAlarm.trigger(alarm1); + const alarm2 = await fakeBrowser.alarms.get(job.id); + + expect(alarm1).toEqual(expected1); + expect(alarm2).toEqual(expected2); + }); + + it('should fail to schedule alarm with an invalid expression', async () => { + const job = { + id: 'cron', + type: 'cron' as const, + expression: '60 */2 * * *', + endDate: Date.now() - 1, + execute: vi.fn(), + }; + const jobs = defineJobScheduler({ logger: null }); + + const actual = jobs.scheduleJob(job); + + await expect(actual).rejects.toThrowError(); + }); + }); + }); + + describe('removeJob', () => { + it('should not execute a job once removed', async () => { + const job = { + id: 'once', + type: 'once' as const, + date: Date.now() + 60e3, + execute: vi.fn(), + }; + const jobs = defineJobScheduler({ logger: null }); + await jobs.scheduleJob(job); + await fakeBrowser.alarms.onAlarm.trigger({ name: job.id, scheduledTime: job.date }); + await jobs.removeJob(job.id); + await fakeBrowser.alarms.onAlarm.trigger({ name: job.id, scheduledTime: job.date }); + const alarm = await fakeBrowser.alarms.get(job.id); + + expect(job.execute).toBeCalledTimes(1); + expect(alarm).toBeUndefined(); + }); + }); + + describe('on', () => { + it('should call success listeners when a job finishes', async () => { + const result = Math.random(); + const job = { + id: 'success', + type: 'once' as const, + date: Date.now() + 1, + execute: vi.fn().mockResolvedValue(result), + }; + const onSuccess = vi.fn(); + + const jobs = defineJobScheduler({ logger: null }); + await jobs.scheduleJob(job); + jobs.on('success', onSuccess); + + await fakeBrowser.alarms.onAlarm.trigger({ name: job.id, scheduledTime: job.date }); + + expect(onSuccess).toBeCalledTimes(1); + expect(onSuccess).toBeCalledWith(job, result); + }); + + it.each([ + // prettier-ignore + ['sync job', vi.fn(() => { throw Error('error'); }), ], + ['async job', vi.fn().mockRejectedValue(Error('error'))], + ])('should call error listeners when the %s fails', async (_, execute) => { + const job = { + id: 'success', + type: 'once' as const, + date: Date.now() + 1, + execute, + }; + const onError = vi.fn(); + + const jobs = defineJobScheduler({ logger: null }); + await jobs.scheduleJob(job); + jobs.on('error', onError); + + await fakeBrowser.alarms.onAlarm.trigger({ name: job.id, scheduledTime: job.date }); + + expect(onError).toBeCalledTimes(1); + expect(onError).toBeCalledWith(job, expect.any(Error)); + }); + }); +}); diff --git a/packages/job-scheduler/src/index.ts b/packages/job-scheduler/src/index.ts new file mode 100644 index 0000000..9c7ffd1 --- /dev/null +++ b/packages/job-scheduler/src/index.ts @@ -0,0 +1,270 @@ +import browser, { Alarms } from 'webextension-polyfill'; +import formatDuration from 'format-duration'; +import cron from 'cron-parser'; + +/** + * Interface used to log text to the console when creating and executing jobs. + */ +export interface Logger { + debug(...args: any[]): void; + log(...args: any[]): void; + warn(...args: any[]): void; + error(...args: any[]): void; +} + +/** + * Configures how the job scheduler behaves. + */ +export interface JobSchedulerConfig { + /** + * The logger to use when logging messages. Set to `null` to disable logging. + * + * @default console + */ + logger?: Logger | null; +} + +/** + * Function ran when executing the job. Errors are automatically caught and will trigger the + * `"error"` event. If a value is returned, the result will be available in the `"success"` event. + */ +export type ExecuteFn = () => Promise | any; + +/** + * A job that executes on a set interval, starting when the job is scheduled for the first time. + */ +export interface IntervalJob { + id: string; + type: 'interval'; + /** + * Interval in milliseconds. Due to limitations of the alarms API, it must be greater than 1 + * minute. + */ + duration: number; + /** + * Execute the job immediately when it is scheduled for the first time. If `false`, it will + * execute for the first time after `duration`. This has no effect when updating an existing job. + * + * @default false + */ + immediate?: boolean; + execute: ExecuteFn; +} + +/** + * A job that is executed based on a CRON expression. Backed by `cron-parser`. + * + * [`cron.ParserOptions`](https://github.com/harrisiirak/cron-parser#options) includes options like timezone. + */ +export interface CronJob extends cron.ParserOptions { + id: string; + type: 'cron'; + /** + * See `cron-parser`'s [supported expressions](https://github.com/harrisiirak/cron-parser#supported-format) + */ + expression: string; + execute: ExecuteFn; +} + +/** + * Runs a job once, at a specific date/time. + */ +export interface OnceJob { + id: string; + type: 'once'; + /** + * The date to run the job on. + */ + date: Date | string | number; + execute: ExecuteFn; +} + +export type Job = IntervalJob | CronJob | OnceJob; + +export interface JobScheduler { + /** + * Schedule a job. If a job with the same `id` has already been scheduled, it will update the job if it is different. + */ + scheduleJob(job: Job): Promise; + /** + * Un-schedules a job by it's ID. + */ + removeJob(jobId: string): Promise; + + /** + * Listen for a job to finish successfully. + */ + on(event: 'success', callback: (job: Job, result: any) => void): RemoveListenerFn; + /** + * Listen for when a job fails. + */ + on(event: 'error', callback: (job: Job, error: unknown) => void): RemoveListenerFn; +} + +/** + * Call to remove the listener that was added. + */ +type RemoveListenerFn = () => void; + +/** + * > Requires the `alarms` permission. + * + * Creates a `JobScheduler` backed by the + * [alarms API](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/alarms). + * + * @param options + * @returns A `JobScheduler` that can be used to schedule and manage jobs. + */ +export function defineJobScheduler(options?: JobSchedulerConfig): JobScheduler { + const logger = options?.logger === undefined ? console : options.logger; + if (browser.alarms == null) { + options; + } + + const successListeners: Array<(job: Job, result: any) => void> = []; + function triggerSuccessListeners(job: Job, result: any) { + successListeners.forEach(l => l(job, result)); + } + + const errorListeners: Array<(job: Job, result: any) => void> = []; + function triggerErrorListeners(job: Job, error: unknown) { + errorListeners.forEach(l => l(job, error)); + } + + /** + * Stores the job callbacks for `onAlarm` + */ + const jobs: Record = {}; + + async function executeJob(job: Job) { + const executionId = String(Math.floor(Math.random() * 1000)).padStart(3, '0'); + logger?.log(`[${executionId}] Executing job:`, job); + + const startTime = Date.now(); + let status = 'success'; + try { + // Schedule next alarm ASAP so that it happens even if the job is killed by non-persistent + // background scripts. + await scheduleNextAlarm(job); + + const result = await job.execute(); + + triggerSuccessListeners(job, result); + } catch (err) { + status = 'failure'; + triggerErrorListeners(job, err); + } + + const endTime = Date.now(); + const durationInMs = endTime - startTime; + logger?.log(`[${executionId}] Job ran in ${formatDuration(durationInMs)}`, { + startTime: new Date(startTime), + endTime: new Date(endTime), + durationInMs, + status, + job, + }); + } + + function jobToAlarm(job: Job): Alarms.Alarm | undefined { + let scheduledTime: number; + let periodInMinutes: number | undefined; + switch (job.type) { + case 'once': + scheduledTime = new Date(job.date).getTime(); + if (scheduledTime < Date.now()) return; + break; + case 'interval': + scheduledTime = Date.now(); + if (!job.immediate) scheduledTime += job.duration; + periodInMinutes = job.duration / 60e3; + break; + case 'cron': + const expression = cron.parseExpression(job.expression, { + ...job, + currentDate: Date.now(), + startDate: Date.now(), + }); + if (!expression.hasNext()) return; + scheduledTime = expression.next().getTime(); + break; + } + return { + name: job.id, + scheduledTime, + periodInMinutes, + }; + } + + async function scheduleJob(job: Job) { + logger?.debug('Scheduling job: ', job); + + // If there's not a future alarm, don't schedule a job. + const alarm = jobToAlarm(job); + if (alarm == null) { + delete jobs[job.id]; + return; + } + + // Create the job if it's different + jobs[job.id] = job; + const existing = (await browser.alarms.get(job.id)) as Alarms.Alarm | undefined; + switch (job.type) { + case 'cron': + case 'once': + if (alarm.scheduledTime !== existing?.scheduledTime) { + browser.alarms.create(alarm.name, { when: alarm.scheduledTime }); + } + break; + case 'interval': + if (!existing || alarm.periodInMinutes !== existing.periodInMinutes) { + browser.alarms.create(alarm.name, { + delayInMinutes: job.immediate && !existing ? 0 : alarm.periodInMinutes, + periodInMinutes: alarm.periodInMinutes, + }); + } + break; + } + } + + /** + * Some jobs need to immediately schedule the next alarm, some don't. This function handles each + * type and calls `scheduleJob` if needed. + */ + async function scheduleNextAlarm(job: Job) { + switch (job.type) { + // A one-time alarm doesn't need a next alarm + case 'once': + // Handled by alarms API + case 'interval': + break; + case 'cron': + await scheduleJob(job); + break; + } + } + + // Listen for alarms and execute jobs + browser.alarms.onAlarm.addListener(async alarm => { + const job = jobs[alarm.name]; + if (job) await executeJob(job); + }); + + return { + scheduleJob, + + async removeJob(jobId) { + delete jobs[jobId]; + await browser.alarms.clear(jobId); + }, + + on(event, callback) { + const listeners = event === 'success' ? successListeners : errorListeners; + listeners.push(callback); + return () => { + const i = listeners.indexOf(callback); + listeners.splice(i, 1); + }; + }, + }; +} diff --git a/packages/job-scheduler/tsconfig.json b/packages/job-scheduler/tsconfig.json new file mode 100644 index 0000000..a0076ca --- /dev/null +++ b/packages/job-scheduler/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "tsconfig/ts-library.json", + "compilerOptions": { + "lib": ["DOM", "ESNext"] + }, + "exclude": ["lib"] +} diff --git a/packages/messaging/README.md b/packages/messaging/README.md index 7a8bbbf..7eb0ff8 100644 --- a/packages/messaging/README.md +++ b/packages/messaging/README.md @@ -33,4 +33,4 @@ console.log(length); // 11 ## Get Started -See [documentation](https://webext-core.aklinker1.io/messaging) to get started! +See [documentation](https://webext-core.aklinker1.io/guide/messaging/) to get started! diff --git a/packages/messaging/src/extension.ts b/packages/messaging/src/extension.ts index 208f257..4cc6432 100644 --- a/packages/messaging/src/extension.ts +++ b/packages/messaging/src/extension.ts @@ -2,6 +2,9 @@ import Browser, { Runtime } from 'webextension-polyfill'; import { GenericMessagingConfig, Message, defineGenericMessanging } from './generic'; import { RemoveListenerCallback } from '.'; +/** + * Configure how the messenger behaves. + */ export interface ExtensionMessagingConfig extends Pick, 'logger'> {} diff --git a/packages/messaging/src/generic.ts b/packages/messaging/src/generic.ts index cb7d06b..3c704c9 100644 --- a/packages/messaging/src/generic.ts +++ b/packages/messaging/src/generic.ts @@ -8,6 +8,11 @@ export interface Message { } export interface GenericMessagingConfig { + /** + * The logger to use when logging messages. Set to `null` to disable logging. + * + * @default console + */ logger?: Logger; sendMessage(message: Message, ...args: TSendMessageArgs): Promise; addRootListener( diff --git a/packages/messaging/src/types.ts b/packages/messaging/src/types.ts index a08876f..822c868 100644 --- a/packages/messaging/src/types.ts +++ b/packages/messaging/src/types.ts @@ -17,6 +17,10 @@ export type MaybePromise = Promise | T; /** * Used to add a return type to a message in the protocol map. * + * > Internally, this is just an object with random keys for the data and return types. + * + * @deprecated Use the function syntax instead: + * * @example * interface ProtocolMap { * // data is a string, returns undefined @@ -24,13 +28,22 @@ export type MaybePromise = Promise | T; * // data is a string, returns a number * type2: ProtocolWithReturn; * } - * - * > Internally, this is just an object with random keys for the data and return types. */ -export type ProtocolWithReturn = { BtVgCTPYZu: TData; RrhVseLgZW: TReturn }; +export interface ProtocolWithReturn { + /** + * Stores the data type. Randomly named so that it isn't accidentally implemented. + */ + BtVgCTPYZu: TData; + /** + * Stores the return type. Randomly named so that it isn't accidentally implemented. + */ + RrhVseLgZW: TReturn; +} /** * Given a function declaration, `ProtocolWithReturn`, or a value, return the message's data type. + * + * @deprecated Use the function syntax instead: */ export type GetDataType = T extends (...args: infer Args) => any ? Args['length'] extends 0 | 1 @@ -42,6 +55,8 @@ export type GetDataType = T extends (...args: infer Args) => any /** * Given a function declaration, `ProtocolWithReturn`, or a value, return the message's return type. + * + * @deprecated Use the function syntax instead: */ export type GetReturnType = T extends (...args: any[]) => infer R ? R diff --git a/packages/proxy-service/README.md b/packages/proxy-service/README.md index 360ffd7..16e0f54 100644 --- a/packages/proxy-service/README.md +++ b/packages/proxy-service/README.md @@ -4,4 +4,4 @@ A type-safe wrapper around the web extension messaging APIs that lets you call a ## Get Started -See [documentation](https://webext-core.aklinker1.io/proxy-service) to get started! +See [documentation](https://webext-core.aklinker1.io/guide/proxy-service/) to get started! diff --git a/packages/proxy-service/package.json b/packages/proxy-service/package.json index 36ec2df..5092009 100644 --- a/packages/proxy-service/package.json +++ b/packages/proxy-service/package.json @@ -1,6 +1,6 @@ { "name": "@webext-core/proxy-service", - "version": "1.1.1", + "version": "1.2.0", "description": "A type-safe wrapper around the web extension messaging APIs that lets you call a function from anywhere, but execute it in the background. Supports all browsers (Chrome, Firefox, Safari, etc)", "license": "MIT", "keywords": [ diff --git a/packages/proxy-service/src/defineProxyService.ts b/packages/proxy-service/src/defineProxyService.ts index a555510..6794fbf 100644 --- a/packages/proxy-service/src/defineProxyService.ts +++ b/packages/proxy-service/src/defineProxyService.ts @@ -3,6 +3,18 @@ import { ProxyService, ProxyServiceConfig, Service } from './types'; import { defineExtensionMessaging, ProtocolWithReturn } from '@webext-core/messaging'; import get from 'get-value'; +/** + * Utility for creating a service whose functions are executed in the background script regardless + * of the JS context the they are called from. + * + * @param name A unique name for the service. Used to identify which service is being executed. + * @param init A function that returns your real service implementation. If args are listed, + * `registerService` will require the same set of arguments. + * @param config + * @returns + * - `registerService`: Used to register your service in the background + * - `getService`: Used to get an instance of the service anywhere in the extension. + */ export function defineProxyService( name: string, init: (...args: TArgs) => TService, diff --git a/packages/proxy-service/src/flattenPromise.test.ts b/packages/proxy-service/src/flattenPromise.test.ts new file mode 100644 index 0000000..a7fa5e3 --- /dev/null +++ b/packages/proxy-service/src/flattenPromise.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import { flattenPromise } from './flattenPromise'; + +describe('flattenPromise', () => { + it('should convert Promise to DeepAsync', async () => { + const fnPromise = Promise.resolve((x: number, y: number) => x + y); + + const fn = flattenPromise(fnPromise); + const actual = await fn(1, 2); + + expect(actual).toBe(3); + }); + + it('should convert shallow Promise to DeepAsync', async () => { + const Object = { + additionalIncrement: 1, + add(x: number, y: number): number { + return x + y + this.additionalIncrement; + }, + }; + const objectPromise = Promise.resolve(Object); + + const object = flattenPromise(objectPromise); + const actual = await object.add(1, 2); + + expect(actual).toBe(4); + }); + + it('should convert nested Promise to DeepAsync', async () => { + const objectPromise = Promise.resolve({ + math: { + additionalIncrement: 1, + add(x: number, y: number): number { + return x + y + this.additionalIncrement; + }, + }, + }); + + const object = flattenPromise(objectPromise); + const actual = await object.math.add(1, 2); + + expect(actual).toBe(4); + }); + + it('should convert Promise to DeepAsync', async () => { + const instancePromise = Promise.resolve( + new (class { + additionalIncrement = 1; + add(x: number, y: number): number { + return x + y + this.additionalIncrement; + } + })(), + ); + + const instance = flattenPromise(instancePromise); + const actual = await instance.add(1, 2); + + expect(actual).toBe(4); + }); +}); diff --git a/packages/proxy-service/src/flattenPromise.ts b/packages/proxy-service/src/flattenPromise.ts new file mode 100644 index 0000000..be4b6ac --- /dev/null +++ b/packages/proxy-service/src/flattenPromise.ts @@ -0,0 +1,51 @@ +import get from 'get-value'; +import { DeepAsync } from './types'; + +/** + * Given a promise of a variable, return a proxy to that awaits the promise internally so you don't + * have to call `await` twice. + * + * > This can be used to simplify handling `Promise` passed in your services. + * + * @example + * function createService(dependencyPromise: Promise) { + * const dependency = flattenPromise(dependencyPromise); + * + * return { + * doSomething() { + * await dependency.someAsyncWork(); + * // Instead of `await (await dependencyPromise).someAsyncWork();` + * } + * } + * } + */ +export function flattenPromise(promise: Promise): DeepAsync { + function createProxy(location?: { propertyPath: string; parentPath?: string }): DeepAsync { + const wrapped = (() => {}) as DeepAsync; + const proxy = new Proxy(wrapped, { + async apply(_target, _thisArg, args) { + const t = (await promise) as any; + const thisArg = (location?.parentPath ? get(t, location.parentPath) : t) as any | undefined; + const fn = (location ? get(t, location.propertyPath) : t) as (...args: any[]) => any; + return fn.apply(thisArg, args); + }, + + // Executed when accessing a property on an object + get(target, propertyName, receiver) { + if (propertyName === '__proxy' || typeof propertyName === 'symbol') { + return Reflect.get(target, propertyName, receiver); + } + return createProxy({ + propertyPath: + location == null ? propertyName : `${location.propertyPath}.${propertyName}`, + parentPath: location?.propertyPath, + }); + }, + }); + // @ts-expect-error: Adding a hidden property + proxy.__proxy = true; + return proxy; + } + + return createProxy(); +} diff --git a/packages/proxy-service/src/index.ts b/packages/proxy-service/src/index.ts index 8b2720a..a506956 100644 --- a/packages/proxy-service/src/index.ts +++ b/packages/proxy-service/src/index.ts @@ -1,2 +1,3 @@ export { defineProxyService } from './defineProxyService'; +export { flattenPromise } from './flattenPromise'; export type { ProxyServiceConfig, ProxyService, DeepAsync } from './types'; diff --git a/packages/proxy-service/src/types.ts b/packages/proxy-service/src/types.ts index 078c4bd..c0eba84 100644 --- a/packages/proxy-service/src/types.ts +++ b/packages/proxy-service/src/types.ts @@ -5,15 +5,16 @@ export type Proimsify = T extends Promise ? T : Promise; export type Service = ((...args: any[]) => Promise) | { [key: string]: any | Service }; /** - * If a service is already fully async, return the original service type. If the service isn't - * completely async, it should return `DeepAsync`. + * A type that ensures a service has only async methods. + * - ***If all methods are async***, it returns the original type. + * - ***If the service has non-async methods***, it returns a `DeepAsync` of the service. */ export type ProxyService = TService extends DeepAsync ? TService : DeepAsync; /** - * Make all functions at all nested levels of an object async + * A recursive type that deeply converts all methods in `TService` to be async. */ export type DeepAsync = TService extends (...args: any) => any ? ToAsyncFunction @@ -27,4 +28,8 @@ type ToAsyncFunction any> = ( ...args: Parameters ) => Proimsify>; +/** + * Configure a proxy service's behavior. It uses `@webext-core/messaging` internally, so any + * config from `ExtensionMessagingConfig` can be passed as well. + */ export interface ProxyServiceConfig extends ExtensionMessagingConfig {} diff --git a/packages/storage/README.md b/packages/storage/README.md index 62ecc23..e703112 100644 --- a/packages/storage/README.md +++ b/packages/storage/README.md @@ -14,4 +14,4 @@ const value = await localExtStorage.getItem('some-key'); ## Get Started -See [documentation](https://webext-core.aklinker1.io/storage) to get started! +See [documentation](https://webext-core.aklinker1.io/guide/storage/) to get started! diff --git a/packages/storage/src/defineExtensionStorage.ts b/packages/storage/src/defineExtensionStorage.ts index 7301c08..3156fb4 100644 --- a/packages/storage/src/defineExtensionStorage.ts +++ b/packages/storage/src/defineExtensionStorage.ts @@ -7,10 +7,19 @@ interface RegisteredChangeListener { } /** - * Create a storage instance with an optional type schema. + * Create a storage instance with an optional schema, `TSchema`, for type safety. * - * @arg storage Either `Browser.storage.local`, `Browser.storage.sync`, or - * `Browser.storage.managed`. This is the storage that will back the implementation. + * @param storage The storage to to use. Either `Browser.storage.local`, `Browser.storage.sync`, or `Browser.storage.managed`. + * + * @example + * import browser from 'webextension-polyfill'; + * + * interface Schema { + * installDate: number; + * } + * const extensionStorage = defineExtensionStorage(browser.storage.local); + * + * const date = await extensionStorage.getItem("installDate"); */ export function defineExtensionStorage( storage: Storage.StorageArea, @@ -84,17 +93,3 @@ export function defineExtensionStorage( }, }; } - -function getStorageName(storage: Storage.StorageArea): 'local' | 'sync' | 'managed' { - switch (storage) { - case browser.storage.local: - return 'local'; - case browser.storage.sync: - return 'sync'; - case browser.storage.managed: - return 'managed'; - } - throw Error( - 'Unsupported storage area. local, sync, and managed are the only supporte storage areas.', - ); -} diff --git a/packages/storage/src/index.ts b/packages/storage/src/index.ts index 5344cb9..84caffc 100644 --- a/packages/storage/src/index.ts +++ b/packages/storage/src/index.ts @@ -4,6 +4,15 @@ import Browser from 'webextension-polyfill'; export { defineExtensionStorage }; +/** + * An implementation of `ExtensionStorage` based on the `browser.storage.local` storage area. + */ export const localExtStorage = defineExtensionStorage(Browser.storage.local); +/** + * An implementation of `ExtensionStorage` based on the `browser.storage.sync` storage area. + */ export const syncExtStorage = defineExtensionStorage(Browser.storage.sync); +/** + * An implementation of `ExtensionStorage` based on the `browser.storage.managed` storage area. + */ export const managedExtStorage = defineExtensionStorage(Browser.storage.managed); diff --git a/packages/storage/src/types.ts b/packages/storage/src/types.ts index 9dd1a87..ba0b4df 100644 --- a/packages/storage/src/types.ts +++ b/packages/storage/src/types.ts @@ -10,6 +10,12 @@ export type OnChangeCallback< TKey extends keyof TSchema = keyof TSchema, > = (newValue: TSchema[TKey], oldValue: TSchema[TKey] | null) => void; +/** + * This is the interface for the storage objects exported from the package. It is similar to `localStorage`, except for a few differences: + * + * - ***It's async*** since the web extension storage APIs are async. + * - It can store any data type, ***not just strings***. + */ export interface ExtensionStorage { /** * Clear all values from storage. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7b354a7..a480a52 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,24 +5,37 @@ importers: .: specifiers: '@aklinker1/generate-changelog': '*' + '@algolia/client-search': ^4.17.0 + '@types/prettier': ^2.7.2 '@types/webextension-polyfill': ^0.9.1 '@webext-core/fake-browser': workspace:* + chokidar: ^3.5.3 husky: ^8.0.1 + linkedom: ^0.14.26 + listr2: ^6.4.2 prettier: ^2.7.1 pretty-quick: ^3.1.3 + ts-morph: ^16.0.0 turbo: ^1.6.3 - vitepress: 1.0.0-alpha.49 + vitepress: 1.0.0-alpha.75 vitest: ^0.29.2 webextension-polyfill: ^0.10.0 + dependencies: + linkedom: 0.14.26 devDependencies: '@aklinker1/generate-changelog': 1.0.0 + '@algolia/client-search': 4.17.0 + '@types/prettier': 2.7.2 '@types/webextension-polyfill': 0.9.1 '@webext-core/fake-browser': link:packages/fake-browser + chokidar: 3.5.3 husky: 8.0.1 + listr2: 6.4.2 prettier: 2.7.1 pretty-quick: 3.1.3_prettier@2.7.1 + ts-morph: 16.0.0 turbo: 1.6.3 - vitepress: 1.0.0-alpha.49 + vitepress: 1.0.0-alpha.75_rurrd674gzxmsn4o6mrapg6sje vitest: 0.29.2 webextension-polyfill: 0.10.0 @@ -79,6 +92,29 @@ importers: devDependencies: vite: 4.0.4 + packages/job-scheduler: + specifiers: + '@vitest/coverage-c8': ^0.24.5 + '@webext-core/fake-browser': workspace:* + cron-parser: ^4.8.1 + format-duration: ^3.0.2 + tsconfig: workspace:* + tsup: ^6.4.0 + typescript: ^4.8.4 + vitest: ^0.24.5 + webextension-polyfill: ^0.10.0 + dependencies: + cron-parser: 4.8.1 + format-duration: 3.0.2 + webextension-polyfill: 0.10.0 + devDependencies: + '@vitest/coverage-c8': 0.24.5 + '@webext-core/fake-browser': link:../fake-browser + tsconfig: link:../tsconfig + tsup: 6.4.0_typescript@4.8.4 + typescript: 4.8.4 + vitest: 0.24.5 + packages/messaging: specifiers: '@types/webextension-polyfill': ^0.9.1 @@ -181,24 +217,25 @@ packages: commander: 9.4.1 dev: true - /@algolia/autocomplete-core/1.7.4: - resolution: {integrity: sha512-daoLpQ3ps/VTMRZDEBfU8ixXd+amZcNJ4QSP3IERGyzqnL5Ch8uSRFt/4G8pUvW9c3o6GA4vtVv4I4lmnkdXyg==} + /@algolia/autocomplete-core/1.8.2: + resolution: {integrity: sha512-mTeshsyFhAqw/ebqNsQpMtbnjr+qVOSKXArEj4K0d7sqc8It1XD0gkASwecm9mF/jlOQ4Z9RNg1HbdA8JPdRwQ==} dependencies: - '@algolia/autocomplete-shared': 1.7.4 + '@algolia/autocomplete-shared': 1.8.2 dev: true - /@algolia/autocomplete-preset-algolia/1.7.4_algoliasearch@4.14.3: - resolution: {integrity: sha512-s37hrvLEIfcmKY8VU9LsAXgm2yfmkdHT3DnA3SgHaY93yjZ2qL57wzb5QweVkYuEBZkT2PIREvRoLXC2sxTbpQ==} + /@algolia/autocomplete-preset-algolia/1.8.2_kr75liv4rlan2ory6clwg3vhfa: + resolution: {integrity: sha512-J0oTx4me6ZM9kIKPuL3lyU3aB8DEvpVvR6xWmHVROx5rOYJGQcZsdG4ozxwcOyiiu3qxMkIbzntnV1S1VWD8yA==} peerDependencies: '@algolia/client-search': '>= 4.9.1 < 6' algoliasearch: '>= 4.9.1 < 6' dependencies: - '@algolia/autocomplete-shared': 1.7.4 + '@algolia/autocomplete-shared': 1.8.2 + '@algolia/client-search': 4.17.0 algoliasearch: 4.14.3 dev: true - /@algolia/autocomplete-shared/1.7.4: - resolution: {integrity: sha512-2VGCk7I9tA9Ge73Km99+Qg87w0wzW4tgUruvWAn/gfey1ZXgmxZtyIRBebk35R1O8TbK77wujVtCnpsGpRy1kg==} + /@algolia/autocomplete-shared/1.8.2: + resolution: {integrity: sha512-b6Z/X4MczChMcfhk6kfRmBzPgjoPzuS9KGR4AFsiLulLNRAAqhP+xZTKtMnZGhLuc61I20d5WqlId02AZvcO6g==} dev: true /@algolia/cache-browser-local-storage/4.14.3: @@ -211,6 +248,10 @@ packages: resolution: {integrity: sha512-oZJofOoD9FQOwiGTzyRnmzvh3ZP8WVTNPBLH5xU5JNF7drDbRT0ocVT0h/xB2rPHYzOeXRrLaQQBwRT/CKom0Q==} dev: true + /@algolia/cache-common/4.17.0: + resolution: {integrity: sha512-g8mXzkrcUBIPZaulAuqE7xyHhLAYAcF2xSch7d9dABheybaU3U91LjBX6eJTEB7XVhEsgK4Smi27vWtAJRhIKQ==} + dev: true + /@algolia/cache-in-memory/4.14.3: resolution: {integrity: sha512-ES0hHQnzWjeioLQf5Nq+x1AWdZJ50znNPSH3puB/Y4Xsg4Av1bvLmTJe7SY2uqONaeMTvL0OaVcoVtQgJVw0vg==} dependencies: @@ -241,6 +282,13 @@ packages: '@algolia/transporter': 4.14.3 dev: true + /@algolia/client-common/4.17.0: + resolution: {integrity: sha512-jHMks0ZFicf8nRDn6ma8DNNsdwGgP/NKiAAL9z6rS7CymJ7L0+QqTJl3rYxRW7TmBhsUH40wqzmrG6aMIN/DrQ==} + dependencies: + '@algolia/requester-common': 4.17.0 + '@algolia/transporter': 4.17.0 + dev: true + /@algolia/client-personalization/4.14.3: resolution: {integrity: sha512-UCX1MtkVNgaOL9f0e22x6tC9e2H3unZQlSUdnVaSKpZ+hdSChXGaRjp2UIT7pxmPqNCyv51F597KEX5WT60jNg==} dependencies: @@ -257,10 +305,22 @@ packages: '@algolia/transporter': 4.14.3 dev: true + /@algolia/client-search/4.17.0: + resolution: {integrity: sha512-x4P2wKrrRIXszT8gb7eWsMHNNHAJs0wE7/uqbufm4tZenAp+hwU/hq5KVsY50v+PfwM0LcDwwn/1DroujsTFoA==} + dependencies: + '@algolia/client-common': 4.17.0 + '@algolia/requester-common': 4.17.0 + '@algolia/transporter': 4.17.0 + dev: true + /@algolia/logger-common/4.14.3: resolution: {integrity: sha512-kUEAZaBt/J3RjYi8MEBT2QEexJR2kAE2mtLmezsmqMQZTV502TkHCxYzTwY2dE7OKcUTxi4OFlMuS4GId9CWPw==} dev: true + /@algolia/logger-common/4.17.0: + resolution: {integrity: sha512-DGuoZqpTmIKJFDeyAJ7M8E/LOenIjWiOsg1XJ1OqAU/eofp49JfqXxbfgctlVZVmDABIyOz8LqEoJ6ZP4DTyvw==} + dev: true + /@algolia/logger-console/4.14.3: resolution: {integrity: sha512-ZWqAlUITktiMN2EiFpQIFCJS10N96A++yrexqC2Z+3hgF/JcKrOxOdT4nSCQoEPvU4Ki9QKbpzbebRDemZt/hw==} dependencies: @@ -277,6 +337,10 @@ packages: resolution: {integrity: sha512-RrRzqNyKFDP7IkTuV3XvYGF9cDPn9h6qEDl595lXva3YUk9YSS8+MGZnnkOMHvjkrSCKfoLeLbm/T4tmoIeclw==} dev: true + /@algolia/requester-common/4.17.0: + resolution: {integrity: sha512-XJjmWFEUlHu0ijvcHBoixuXfEoiRUdyzQM6YwTuB8usJNIgShua8ouFlRWF8iCeag0vZZiUm4S2WCVBPkdxFgg==} + dev: true + /@algolia/requester-node-http/4.14.3: resolution: {integrity: sha512-O5wnPxtDRPuW2U0EaOz9rMMWdlhwP0J0eSL1Z7TtXF8xnUeeUyNJrdhV5uy2CAp6RbhM1VuC3sOJcIR6Av+vbA==} dependencies: @@ -291,6 +355,14 @@ packages: '@algolia/requester-common': 4.14.3 dev: true + /@algolia/transporter/4.17.0: + resolution: {integrity: sha512-6xL6H6fe+Fi0AEP3ziSgC+G04RK37iRb4uUUqVAH9WPYFI8g+LYFq6iv5HS8Cbuc5TTut+Bwj6G+dh/asdb9uA==} + dependencies: + '@algolia/cache-common': 4.17.0 + '@algolia/logger-common': 4.17.0 + '@algolia/requester-common': 4.17.0 + dev: true + /@babel/code-frame/7.18.6: resolution: {integrity: sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q==} engines: {node: '>=6.9.0'} @@ -371,14 +443,14 @@ packages: - supports-color dev: true - /@docsearch/css/3.3.3: - resolution: {integrity: sha512-6SCwI7P8ao+se1TUsdZ7B4XzL+gqeQZnBc+2EONZlcVa0dVrk0NjETxozFKgMv0eEGH8QzP1fkN+A1rH61l4eg==} + /@docsearch/css/3.3.4: + resolution: {integrity: sha512-vDwCDoVXDgopw/hvr0zEADew2wWaGP8Qq0Bxhgii1Ewz2t4fQeyJwIRN/mWADeLFYPVkpz8TpEbxya/i6Tm0WA==} dev: true - /@docsearch/js/3.3.3: - resolution: {integrity: sha512-2xAv2GFuHzzmG0SSZgf8wHX0qZX8n9Y1ZirKUk5Wrdc+vH9CL837x2hZIUdwcPZI9caBA+/CzxsS68O4waYjUQ==} + /@docsearch/js/3.3.4_rurrd674gzxmsn4o6mrapg6sje: + resolution: {integrity: sha512-Xd2saBziXJ1UuVpcDz94zAFEFAM6ap993agh0za2e3LDZLhaW993b1f9gyUL4e1CZLsR076tztG2un2gVncvpA==} dependencies: - '@docsearch/react': 3.3.3 + '@docsearch/react': 3.3.4_rurrd674gzxmsn4o6mrapg6sje preact: 10.11.3 transitivePeerDependencies: - '@algolia/client-search' @@ -387,8 +459,8 @@ packages: - react-dom dev: true - /@docsearch/react/3.3.3: - resolution: {integrity: sha512-pLa0cxnl+G0FuIDuYlW+EBK6Rw2jwLw9B1RHIeS4N4s2VhsfJ/wzeCi3CWcs5yVfxLd5ZK50t//TMA5e79YT7Q==} + /@docsearch/react/3.3.4_rurrd674gzxmsn4o6mrapg6sje: + resolution: {integrity: sha512-aeOf1WC5zMzBEi2SI6WWznOmIo9rnpN4p7a3zHXxowVciqlI4HsZGtOR9nFOufLeolv7HibwLlaM0oyUqJxasw==} peerDependencies: '@types/react': '>= 16.8.0 < 19.0.0' react: '>= 16.8.0 < 19.0.0' @@ -401,9 +473,9 @@ packages: react-dom: optional: true dependencies: - '@algolia/autocomplete-core': 1.7.4 - '@algolia/autocomplete-preset-algolia': 1.7.4_algoliasearch@4.14.3 - '@docsearch/css': 3.3.3 + '@algolia/autocomplete-core': 1.8.2 + '@algolia/autocomplete-preset-algolia': 1.8.2_kr75liv4rlan2ory6clwg3vhfa + '@docsearch/css': 3.3.4 algoliasearch: 4.14.3 transitivePeerDependencies: - '@algolia/client-search' @@ -448,6 +520,15 @@ packages: dev: true optional: true + /@esbuild/android-arm/0.17.18: + resolution: {integrity: sha512-EmwL+vUBZJ7mhFCs5lA4ZimpUH3WMAoqvOIYhVQwdIgSpHC8ImHdsRyhHAVxpDYUSm0lWvd63z0XH1IlImS2Qw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + requiresBuild: true + dev: true + optional: true + /@esbuild/android-arm64/0.16.15: resolution: {integrity: sha512-OdbkUv7468dSsgoFtHIwTaYAuI5lDEv/v+dlfGBUbVa2xSDIIuSOHXawynw5N9+5lygo/JdXa5/sgGjiEU18gQ==} engines: {node: '>=12'} @@ -457,6 +538,15 @@ packages: dev: true optional: true + /@esbuild/android-arm64/0.17.18: + resolution: {integrity: sha512-/iq0aK0eeHgSC3z55ucMAHO05OIqmQehiGay8eP5l/5l+iEr4EIbh4/MI8xD9qRFjqzgkc0JkX0LculNC9mXBw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + requiresBuild: true + dev: true + optional: true + /@esbuild/android-x64/0.16.15: resolution: {integrity: sha512-dPUOBiNNWAm+/bxoA75o7R7qqqfcEzXaYlb5uJk2xGHmUMNKSAnDCtRYLgx9/wfE4sXyn8H948OrDyUAHhPOuA==} engines: {node: '>=12'} @@ -466,6 +556,15 @@ packages: dev: true optional: true + /@esbuild/android-x64/0.17.18: + resolution: {integrity: sha512-x+0efYNBF3NPW2Xc5bFOSFW7tTXdAcpfEg2nXmxegm4mJuVeS+i109m/7HMiOQ6M12aVGGFlqJX3RhNdYM2lWg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + requiresBuild: true + dev: true + optional: true + /@esbuild/darwin-arm64/0.16.15: resolution: {integrity: sha512-AksarYV85Hxgwh5/zb6qGl4sYWxIXPQGBAZ+jUro1ZpINy3EWumK+/4DPOKUBPnsrOIvnNXy7Rq4mTeCsMQDNA==} engines: {node: '>=12'} @@ -475,6 +574,15 @@ packages: dev: true optional: true + /@esbuild/darwin-arm64/0.17.18: + resolution: {integrity: sha512-6tY+djEAdF48M1ONWnQb1C+6LiXrKjmqjzPNPWXhu/GzOHTHX2nh8Mo2ZAmBFg0kIodHhciEgUBtcYCAIjGbjQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + /@esbuild/darwin-x64/0.16.15: resolution: {integrity: sha512-qqrKJxoohceZGGP+sZ5yXkzW9ZiyFZJ1gWSEfuYdOWzBSL18Uy3w7s/IvnDYHo++/cxwqM0ch3HQVReSZy7/4Q==} engines: {node: '>=12'} @@ -484,6 +592,15 @@ packages: dev: true optional: true + /@esbuild/darwin-x64/0.17.18: + resolution: {integrity: sha512-Qq84ykvLvya3dO49wVC9FFCNUfSrQJLbxhoQk/TE1r6MjHo3sFF2tlJCwMjhkBVq3/ahUisj7+EpRSz0/+8+9A==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + /@esbuild/freebsd-arm64/0.16.15: resolution: {integrity: sha512-LBWaep6RvJm5KnsKkocdVEzuwnGMjz54fcRVZ9d3R7FSEWOtPBxMhuxeA1n98JVbCLMkTPFmKN6xSnfhnM9WXQ==} engines: {node: '>=12'} @@ -493,6 +610,15 @@ packages: dev: true optional: true + /@esbuild/freebsd-arm64/0.17.18: + resolution: {integrity: sha512-fw/ZfxfAzuHfaQeMDhbzxp9mc+mHn1Y94VDHFHjGvt2Uxl10mT4CDavHm+/L9KG441t1QdABqkVYwakMUeyLRA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + /@esbuild/freebsd-x64/0.16.15: resolution: {integrity: sha512-LE8mKC6JPR04kPLRP9A6k7ZmG0k2aWF4ru79Sde6UeWCo7yDby5f48uJNFQ2pZqzUUkLrHL8xNdIHerJeZjHXg==} engines: {node: '>=12'} @@ -502,6 +628,15 @@ packages: dev: true optional: true + /@esbuild/freebsd-x64/0.17.18: + resolution: {integrity: sha512-FQFbRtTaEi8ZBi/A6kxOC0V0E9B/97vPdYjY9NdawyLd4Qk5VD5g2pbWN2VR1c0xhzcJm74HWpObPszWC+qTew==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + /@esbuild/linux-arm/0.16.15: resolution: {integrity: sha512-+1sGlqtMJTOnJUXwLUGnDhPaGRKqxT0UONtYacS+EjdDOrSgpQ/1gUXlnze45Z/BogwYaswQM19Gu1YD1T19/w==} engines: {node: '>=12'} @@ -511,6 +646,15 @@ packages: dev: true optional: true + /@esbuild/linux-arm/0.17.18: + resolution: {integrity: sha512-jW+UCM40LzHcouIaqv3e/oRs0JM76JfhHjCavPxMUti7VAPh8CaGSlS7cmyrdpzSk7A+8f0hiedHqr/LMnfijg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@esbuild/linux-arm64/0.16.15: resolution: {integrity: sha512-mRYpuQGbzY+XLczy3Sk7fMJ3DRKLGDIuvLKkkUkyecDGQMmil6K/xVKP9IpKO7JtNH477qAiMjjX7jfKae8t4g==} engines: {node: '>=12'} @@ -520,6 +664,15 @@ packages: dev: true optional: true + /@esbuild/linux-arm64/0.17.18: + resolution: {integrity: sha512-R7pZvQZFOY2sxUG8P6A21eq6q+eBv7JPQYIybHVf1XkQYC+lT7nDBdC7wWKTrbvMXKRaGudp/dzZCwL/863mZQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@esbuild/linux-ia32/0.16.15: resolution: {integrity: sha512-puXVFvY4m8EB6/fzu3LdgjiNnEZ3gZMSR7NmKoQe51l3hyQalvTjab3Dt7aX4qGf+8Pj7dsCOBNzNzkSlr/4Aw==} engines: {node: '>=12'} @@ -529,6 +682,15 @@ packages: dev: true optional: true + /@esbuild/linux-ia32/0.17.18: + resolution: {integrity: sha512-ygIMc3I7wxgXIxk6j3V00VlABIjq260i967Cp9BNAk5pOOpIXmd1RFQJQX9Io7KRsthDrQYrtcx7QCof4o3ZoQ==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@esbuild/linux-loong64/0.15.13: resolution: {integrity: sha512-+BoyIm4I8uJmH/QDIH0fu7MG0AEx9OXEDXnqptXCwKOlOqZiS4iraH1Nr7/ObLMokW3sOCeBNyD68ATcV9b9Ag==} engines: {node: '>=12'} @@ -547,6 +709,15 @@ packages: dev: true optional: true + /@esbuild/linux-loong64/0.17.18: + resolution: {integrity: sha512-bvPG+MyFs5ZlwYclCG1D744oHk1Pv7j8psF5TfYx7otCVmcJsEXgFEhQkbhNW8otDHL1a2KDINW20cfCgnzgMQ==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@esbuild/linux-mips64el/0.16.15: resolution: {integrity: sha512-3SEA4L82OnoSATW+Ve8rPgLaKjC8WMt8fnx7De9kvi/NcVbkj8W+J7qnu/tK2P9pUPQP7Au/0sjPEqZtFeyKQQ==} engines: {node: '>=12'} @@ -556,6 +727,15 @@ packages: dev: true optional: true + /@esbuild/linux-mips64el/0.17.18: + resolution: {integrity: sha512-oVqckATOAGuiUOa6wr8TXaVPSa+6IwVJrGidmNZS1cZVx0HqkTMkqFGD2HIx9H1RvOwFeWYdaYbdY6B89KUMxA==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@esbuild/linux-ppc64/0.16.15: resolution: {integrity: sha512-8PgbeX+N6vmqeySzyxO0NyDOltCEW13OS5jUHTvCHmCgf4kNXZtAWJ+zEfJxjRGYhVezQ1FdIm7WfN1R27uOyg==} engines: {node: '>=12'} @@ -565,6 +745,15 @@ packages: dev: true optional: true + /@esbuild/linux-ppc64/0.17.18: + resolution: {integrity: sha512-3dLlQO+b/LnQNxgH4l9rqa2/IwRJVN9u/bK63FhOPB4xqiRqlQAU0qDU3JJuf0BmaH0yytTBdoSBHrb2jqc5qQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@esbuild/linux-riscv64/0.16.15: resolution: {integrity: sha512-U+coqH+89vbPVoU30no1Fllrn6gvEeO5tfEArBhjYZ+dQ3Gv7ciQXYf5nrT1QdlIFwEjH4Is1U1iiaGWW+tGpQ==} engines: {node: '>=12'} @@ -574,6 +763,15 @@ packages: dev: true optional: true + /@esbuild/linux-riscv64/0.17.18: + resolution: {integrity: sha512-/x7leOyDPjZV3TcsdfrSI107zItVnsX1q2nho7hbbQoKnmoeUWjs+08rKKt4AUXju7+3aRZSsKrJtaRmsdL1xA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@esbuild/linux-s390x/0.16.15: resolution: {integrity: sha512-M0nKLFMdyFGBoitxG42kq6Xap0CPeDC6gfF9lg7ZejzGF6kqYUGT+pQGl2QCQoxJBeat/LzTma1hG8C3dq2ocg==} engines: {node: '>=12'} @@ -583,6 +781,15 @@ packages: dev: true optional: true + /@esbuild/linux-s390x/0.17.18: + resolution: {integrity: sha512-cX0I8Q9xQkL/6F5zWdYmVf5JSQt+ZfZD2bJudZrWD+4mnUvoZ3TDDXtDX2mUaq6upMFv9FlfIh4Gfun0tbGzuw==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@esbuild/linux-x64/0.16.15: resolution: {integrity: sha512-t7/fOXBUKfigvhJLGKZ9TPHHgqNgpIpYaAbcXQk1X+fPeUG7x0tpAbXJ2wST9F/gJ02+CLETPMnhG7Tra2wqsQ==} engines: {node: '>=12'} @@ -592,6 +799,15 @@ packages: dev: true optional: true + /@esbuild/linux-x64/0.17.18: + resolution: {integrity: sha512-66RmRsPlYy4jFl0vG80GcNRdirx4nVWAzJmXkevgphP1qf4dsLQCpSKGM3DUQCojwU1hnepI63gNZdrr02wHUA==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@esbuild/netbsd-x64/0.16.15: resolution: {integrity: sha512-0k0Nxi6DOJmTnLtKD/0rlyqOPpcqONXY53vpkoAsue8CfyhNPWtwzba1ICFNCfCY1dqL3Ho/xEzujJhmdXq1rg==} engines: {node: '>=12'} @@ -601,6 +817,15 @@ packages: dev: true optional: true + /@esbuild/netbsd-x64/0.17.18: + resolution: {integrity: sha512-95IRY7mI2yrkLlTLb1gpDxdC5WLC5mZDi+kA9dmM5XAGxCME0F8i4bYH4jZreaJ6lIZ0B8hTrweqG1fUyW7jbg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + requiresBuild: true + dev: true + optional: true + /@esbuild/openbsd-x64/0.16.15: resolution: {integrity: sha512-3SkckazfIbdSjsGpuIYT3d6n2Hx0tck3MS1yVsbahhWiLvdy4QozTpvlbjqO3GmvtvhxY4qdyhFOO2wiZKeTAQ==} engines: {node: '>=12'} @@ -610,6 +835,15 @@ packages: dev: true optional: true + /@esbuild/openbsd-x64/0.17.18: + resolution: {integrity: sha512-WevVOgcng+8hSZ4Q3BKL3n1xTv5H6Nb53cBrtzzEjDbbnOmucEVcZeGCsCOi9bAOcDYEeBZbD2SJNBxlfP3qiA==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + requiresBuild: true + dev: true + optional: true + /@esbuild/sunos-x64/0.16.15: resolution: {integrity: sha512-8PNvBC+O8X5EnyIGqE8St2bOjjrXMR17NOLenIrzolvwWnJXvwPo0tE/ahOeiAJmTOS/eAcN8b4LAZcn17Uj7w==} engines: {node: '>=12'} @@ -619,6 +853,15 @@ packages: dev: true optional: true + /@esbuild/sunos-x64/0.17.18: + resolution: {integrity: sha512-Rzf4QfQagnwhQXVBS3BYUlxmEbcV7MY+BH5vfDZekU5eYpcffHSyjU8T0xucKVuOcdCsMo+Ur5wmgQJH2GfNrg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + requiresBuild: true + dev: true + optional: true + /@esbuild/win32-arm64/0.16.15: resolution: {integrity: sha512-YPaSgm/mm7kNcATB53OxVGVfn6rDNbImTn330ZlF3hKej1e9ktCaljGjn2vH08z2dlHEf3kdt57tNjE6zs8SzA==} engines: {node: '>=12'} @@ -628,6 +871,15 @@ packages: dev: true optional: true + /@esbuild/win32-arm64/0.17.18: + resolution: {integrity: sha512-Kb3Ko/KKaWhjeAm2YoT/cNZaHaD1Yk/pa3FTsmqo9uFh1D1Rfco7BBLIPdDOozrObj2sahslFuAQGvWbgWldAg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: true + optional: true + /@esbuild/win32-ia32/0.16.15: resolution: {integrity: sha512-0movUXbSNrTeNf5ZXT0avklEvlJD0hNGZsrrXHfsp9z4tK5xC+apCqmUEZeE9mqrb84Z8XbgGr/MS9LqafTP2A==} engines: {node: '>=12'} @@ -637,6 +889,15 @@ packages: dev: true optional: true + /@esbuild/win32-ia32/0.17.18: + resolution: {integrity: sha512-0/xUMIdkVHwkvxfbd5+lfG7mHOf2FRrxNbPiKWg9C4fFrB8H0guClmaM3BFiRUYrznVoyxTIyC/Ou2B7QQSwmw==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: true + optional: true + /@esbuild/win32-x64/0.16.15: resolution: {integrity: sha512-27h5GCcbfomVAqAnMJWvR1LqEY0dFqIq4vTe5nY3becnZNu0SX8F0+gTk3JPvgWQHzaGc6VkPzlOiMkdSUunUA==} engines: {node: '>=12'} @@ -646,6 +907,15 @@ packages: dev: true optional: true + /@esbuild/win32-x64/0.17.18: + resolution: {integrity: sha512-qU25Ma1I3NqTSHJUOKi9sAH1/Mzuvlke0ioMJRthLXKm7JiSKVwFghlGbDLOO2sARECGhja4xYfRAZNPAkooYg==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + /@eslint/eslintrc/1.3.3: resolution: {integrity: sha512-uj3pT6Mg+3t39fvLrj8iuCIJ38zKO9FpGtJ4BBJebJhEwjoT+KLVNCcHT5QC9NGRIEi7fZ0ZR8YRb884auB4Lg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -822,8 +1092,12 @@ packages: resolution: {integrity: sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==} dev: true - /@types/web-bluetooth/0.0.16: - resolution: {integrity: sha512-oh8q2Zc32S6gd/j50GowEjKLoOVOwHP/bWVjKJInBwQqdOYMdPrf1oVlelTlyfFK3CKxL1uahMDAr+vy8T7yMQ==} + /@types/prettier/2.7.2: + resolution: {integrity: sha512-KufADq8uQqo1pYKVIYzfKbJfBAc0sOeXqGbFaSpv8MRmC/zXgowNZmFcbngndGk922QDmOASEXUZCaY48gs4cg==} + dev: true + + /@types/web-bluetooth/0.0.17: + resolution: {integrity: sha512-4p9vcSmxAayx72yn70joFoL44c9MO/0+iVEBIQXe3v2h2SiAsEIo/G5v6ObFWvNKRFjbrVadNf9LqEEZeQPzdA==} dev: true /@types/webextension-polyfill/0.9.1: @@ -836,14 +1110,14 @@ packages: '@types/node': 18.11.9 dev: true - /@vitejs/plugin-vue/4.0.0_vite@4.1.4+vue@3.2.47: - resolution: {integrity: sha512-e0X4jErIxAB5oLtDqbHvHpJe/uWNkdpYV83AOG2xo2tEVSzCzewgJMtREZM30wXnM5ls90hxiOtAuVU6H5JgbA==} + /@vitejs/plugin-vue/4.2.1_vite@4.3.5+vue@3.2.47: + resolution: {integrity: sha512-ZTZjzo7bmxTRTkb8GSTwkPOYDIP7pwuyV+RV53c9PYUouwcbkIZIvWvNWlX2b1dYZqtOv7D6iUAnJLVNGcLrSw==} engines: {node: ^14.18.0 || >=16.0.0} peerDependencies: vite: ^4.0.0 vue: ^3.2.25 dependencies: - vite: 4.1.4 + vite: 4.3.5 vue: 3.2.47 dev: true @@ -1078,26 +1352,26 @@ packages: resolution: {integrity: sha512-BHGyyGN3Q97EZx0taMQ+OLNuZcW3d37ZEVmEAyeoA9ERdGvm9Irc/0Fua8SNyOtV1w6BS4q25wbMzJujO9HIfQ==} dev: true - /@vueuse/core/9.13.0_vue@3.2.47: - resolution: {integrity: sha512-pujnclbeHWxxPRqXWmdkKV5OX4Wk4YeK7wusHqRwU0Q7EFusHoqNA/aPhB6KCh9hEqJkLAJo7bb0Lh9b+OIVzw==} + /@vueuse/core/10.1.2_vue@3.2.47: + resolution: {integrity: sha512-roNn8WuerI56A5uiTyF/TEYX0Y+VKlhZAF94unUfdhbDUI+NfwQMn4FUnUscIRUhv3344qvAghopU4bzLPNFlA==} dependencies: - '@types/web-bluetooth': 0.0.16 - '@vueuse/metadata': 9.13.0 - '@vueuse/shared': 9.13.0_vue@3.2.47 - vue-demi: 0.13.11_vue@3.2.47 + '@types/web-bluetooth': 0.0.17 + '@vueuse/metadata': 10.1.2 + '@vueuse/shared': 10.1.2_vue@3.2.47 + vue-demi: 0.14.1_vue@3.2.47 transitivePeerDependencies: - '@vue/composition-api' - vue dev: true - /@vueuse/metadata/9.13.0: - resolution: {integrity: sha512-gdU7TKNAUVlXXLbaF+ZCfte8BjRJQWPCa2J55+7/h+yDtzw3vOoGQDRXzI6pyKyo6bXFT5/QoPE4hAknExjRLQ==} + /@vueuse/metadata/10.1.2: + resolution: {integrity: sha512-3mc5BqN9aU2SqBeBuWE7ne4OtXHoHKggNgxZR2K+zIW4YLsy6xoZ4/9vErQs6tvoKDX6QAqm3lvsrv0mczAwIQ==} dev: true - /@vueuse/shared/9.13.0_vue@3.2.47: - resolution: {integrity: sha512-UrnhU+Cnufu4S6JLCPZnkWh0WwZGUp72ktOF2DFptMlOs3TOdVv8xJN53zhHGARmVOsz5KqOls09+J1NR6sBKw==} + /@vueuse/shared/10.1.2_vue@3.2.47: + resolution: {integrity: sha512-1uoUTPBlgyscK9v6ScGeVYDDzlPSFXBlxuK7SfrDGyUTBiznb3mNceqhwvZHjtDRELZEN79V5uWPTF1VDV8svA==} dependencies: - vue-demi: 0.13.11_vue@3.2.47 + vue-demi: 0.14.1_vue@3.2.47 transitivePeerDependencies: - '@vue/composition-api' - vue @@ -1308,6 +1582,13 @@ packages: string-width: 4.2.3 dev: true + /ansi-escapes/5.0.0: + resolution: {integrity: sha512-5GFMVX8HqE/TB+FuBJGuO5XG0WrsA6ptUqoODaT/n9mmUaZFkqnBueB4leqGBCmrUHnCnC4PCZTCd0E7QQ83bA==} + engines: {node: '>=12'} + dependencies: + type-fest: 1.4.0 + dev: true + /ansi-regex/5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -1461,7 +1742,6 @@ packages: /boolbase/1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} - dev: true /boxen/7.0.0: resolution: {integrity: sha512-j//dBVuyacJbvW+tvZ9HuH03fZ46QcaKvvhZickZqtB271DxJ7SNRSNxrV/dZX0085m7hISRZWbzWlJvx/rHSg==} @@ -1719,6 +1999,13 @@ packages: engines: {node: '>=10'} dev: true + /cli-cursor/4.0.0: + resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dependencies: + restore-cursor: 4.0.0 + dev: true + /cli-truncate/3.1.0: resolution: {integrity: sha512-wfOBkjXteqSnI59oPcJkcPl/ZmwvMMOj340qUIY1SKZCv0B9Cf4D4fAucRkIKQmsIuYK3x1rrgU7MeGRruiuiA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -1774,6 +2061,10 @@ packages: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} dev: true + /colorette/2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + dev: true + /columnify/1.6.0: resolution: {integrity: sha512-lomjuFZKfM6MSAnV9aCZC9sc0qGbmZdfygNv+nCpqVkSKdCxCklLtd16O0EILGkImHw9ZpHkAnHaB+8Zxq5W6Q==} engines: {node: '>=8.0.0'} @@ -1871,6 +2162,13 @@ packages: yaml: 1.10.2 dev: true + /cron-parser/4.8.1: + resolution: {integrity: sha512-jbokKWGcyU4gl6jAfX97E1gDpY12DJ1cLJZmoDzaAln/shZ+S3KBFBuA2Q6WeUN4gJf/8klnV1EfvhA2lK5IRQ==} + engines: {node: '>=12.0.0'} + dependencies: + luxon: 3.3.0 + dev: false + /cross-spawn/7.0.3: resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} engines: {node: '>= 8'} @@ -1899,12 +2197,10 @@ packages: domhandler: 5.0.3 domutils: 3.0.1 nth-check: 2.1.1 - dev: true /css-what/6.1.0: resolution: {integrity: sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==} engines: {node: '>= 6'} - dev: true /cssom/0.3.8: resolution: {integrity: sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==} @@ -1912,7 +2208,6 @@ packages: /cssom/0.5.0: resolution: {integrity: sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==} - dev: true /cssstyle/2.3.0: resolution: {integrity: sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==} @@ -2069,11 +2364,9 @@ packages: domelementtype: 2.3.0 domhandler: 5.0.3 entities: 4.4.0 - dev: true /domelementtype/2.3.0: resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} - dev: true /domexception/4.0.0: resolution: {integrity: sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==} @@ -2087,7 +2380,6 @@ packages: engines: {node: '>= 4'} dependencies: domelementtype: 2.3.0 - dev: true /domutils/3.0.1: resolution: {integrity: sha512-z08c1l761iKhDFtfXO04C7kTdPBLi41zwOZl00WS8b5eiaebNpY00HKbztwBq+e3vyqWNwWF3mP9YLUeqIrF+Q==} @@ -2095,7 +2387,6 @@ packages: dom-serializer: 2.0.0 domelementtype: 2.3.0 domhandler: 5.0.3 - dev: true /dot-prop/6.0.1: resolution: {integrity: sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==} @@ -2147,7 +2438,6 @@ packages: /entities/4.4.0: resolution: {integrity: sha512-oYp7156SP8LkeGD0GF85ad1X9Ai79WtRsZ2gxJqtBuzH+98YUV6jkHEKlZkMbcrjJjIVJNIDP/3WL9wQkoPbWA==} engines: {node: '>=0.12'} - dev: true /error-ex/1.3.2: resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} @@ -2404,6 +2694,36 @@ packages: '@esbuild/win32-x64': 0.16.15 dev: true + /esbuild/0.17.18: + resolution: {integrity: sha512-z1lix43jBs6UKjcZVKOw2xx69ffE2aG0PygLL5qJ9OS/gy0Ewd1gW/PUQIOIQGXBHWNywSc0floSKoMFF8aK2w==} + engines: {node: '>=12'} + hasBin: true + requiresBuild: true + optionalDependencies: + '@esbuild/android-arm': 0.17.18 + '@esbuild/android-arm64': 0.17.18 + '@esbuild/android-x64': 0.17.18 + '@esbuild/darwin-arm64': 0.17.18 + '@esbuild/darwin-x64': 0.17.18 + '@esbuild/freebsd-arm64': 0.17.18 + '@esbuild/freebsd-x64': 0.17.18 + '@esbuild/linux-arm': 0.17.18 + '@esbuild/linux-arm64': 0.17.18 + '@esbuild/linux-ia32': 0.17.18 + '@esbuild/linux-loong64': 0.17.18 + '@esbuild/linux-mips64el': 0.17.18 + '@esbuild/linux-ppc64': 0.17.18 + '@esbuild/linux-riscv64': 0.17.18 + '@esbuild/linux-s390x': 0.17.18 + '@esbuild/linux-x64': 0.17.18 + '@esbuild/netbsd-x64': 0.17.18 + '@esbuild/openbsd-x64': 0.17.18 + '@esbuild/sunos-x64': 0.17.18 + '@esbuild/win32-arm64': 0.17.18 + '@esbuild/win32-ia32': 0.17.18 + '@esbuild/win32-x64': 0.17.18 + dev: true + /escalade/3.1.1: resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==} engines: {node: '>=6'} @@ -2577,6 +2897,10 @@ packages: engines: {node: '>=6'} dev: true + /eventemitter3/5.0.1: + resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + dev: true + /events/3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} @@ -2778,6 +3102,10 @@ packages: mime-types: 2.1.35 dev: true + /format-duration/3.0.2: + resolution: {integrity: sha512-pKzJDSRgK2lqAiPW3uizDaIJaJnataZclsahz25UMwfdryBGDa+1HlbXGjzpMvX/2kMh4O0sNevFXKaEfCjHsA==} + dev: false + /formdata-polyfill/4.0.10: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} @@ -3040,7 +3368,6 @@ packages: /html-escaper/3.0.3: resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} - dev: true /htmlparser2/8.0.1: resolution: {integrity: sha512-4lVbmc1diZC7GUJQtRQ5yBAeUCL1exyMwmForWkRLnwyzWBFxN633SALPMGYaWZvKe9j1pRZJpauvmxENSp/EA==} @@ -3049,7 +3376,6 @@ packages: domhandler: 5.0.3 domutils: 3.0.1 entities: 4.4.0 - dev: true /http-cache-semantics/4.1.0: resolution: {integrity: sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==} @@ -3602,6 +3928,33 @@ packages: uhyphen: 0.2.0 dev: true + /linkedom/0.14.26: + resolution: {integrity: sha512-mK6TrydfFA7phrnp+1j57ycBwFI5bGSW6YXlw9acHoqF+mP/y+FooEYYyniOt5Ot57FSKB3iwmnuQ1UUyNLm5A==} + dependencies: + css-select: 5.1.0 + cssom: 0.5.0 + html-escaper: 3.0.3 + htmlparser2: 8.0.1 + uhyphen: 0.2.0 + dev: false + + /listr2/6.4.2: + resolution: {integrity: sha512-v55SFIDP7SiPEYFeIFGbKW44B4NPpqGEklbAc1EKacMxIqFVXpDlc93e/Q6hE3IgIGRu5870rh5yJc+ESwGUpQ==} + engines: {node: '>=16.0.0'} + peerDependencies: + enquirer: '>= 2.3.0 < 3' + peerDependenciesMeta: + enquirer: + optional: true + dependencies: + cli-truncate: 3.1.0 + colorette: 2.0.20 + eventemitter3: 5.0.1 + log-update: 5.0.1 + rfdc: 1.3.0 + wrap-ansi: 8.1.0 + dev: true + /load-tsconfig/0.2.3: resolution: {integrity: sha512-iyT2MXws+dc2Wi6o3grCFtGXpeMvHmJqS27sMPGtV2eUu4PeFnG+33I8BlFK1t1NWMjOpcx9bridn5yxLDX2gQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -3669,6 +4022,17 @@ packages: resolution: {integrity: sha512-e/zcLx6CSbmaEgFHCA7BnoQKyCtKMxnuWrJygbwPs/AIn+IMKl66L8/s+wBUn5LRw2pZx3bUHibiV1b6aTWIww==} dev: true + /log-update/5.0.1: + resolution: {integrity: sha512-5UtUDQ/6edw4ofyljDNcOVJQ4c7OjDro4h3y8e1GQL5iYElYclVHJ3zeWchylvMaKnDbDilC8irOVyexnA/Slw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dependencies: + ansi-escapes: 5.0.0 + cli-cursor: 4.0.0 + slice-ansi: 5.0.0 + strip-ansi: 7.0.1 + wrap-ansi: 8.1.0 + dev: true + /loupe/2.3.5: resolution: {integrity: sha512-KNGVjhsXDxvY/cYE8GNi7SBaJSfJIT+/+/8GlprqBXpoU6cSR7/RT7OBJOsoYtyxq0L3q6oIcO8tX7dbEEXr3A==} dependencies: @@ -3693,6 +4057,11 @@ packages: yallist: 4.0.0 dev: true + /luxon/3.3.0: + resolution: {integrity: sha512-An0UCfG/rSiqtAIiBPO0Y9/zAnHUZxAMiCpTd5h2smgsj7GGmcenvrvww2cqNA8/4A5ZrD1gJpHN2mIHZQF+Mg==} + engines: {node: '>=12'} + dev: false + /magic-string/0.25.9: resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==} dependencies: @@ -3717,6 +4086,10 @@ packages: p-defer: 1.0.0 dev: true + /mark.js/8.11.1: + resolution: {integrity: sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==} + dev: true + /marky/1.2.5: resolution: {integrity: sha512-q9JtQJKjpsVxCRVgQ+WapguSbKC3SQ5HEzFGPAJMStgh3QjCawp00UKv3MTTAArTmGmmPUvllHZoNbZ3gs0I+Q==} dev: true @@ -3799,6 +4172,10 @@ packages: resolution: {integrity: sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g==} dev: true + /minisearch/6.0.1: + resolution: {integrity: sha512-Ly1w0nHKnlhAAh6/BF/+9NgzXfoJxaJ8nhopFhQ3NcvFJrFIL+iCg9gw9e9UMBD+XIsp/RyznJ/o5UIe5Kw+kg==} + dev: true + /mkdirp/0.5.6: resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} hasBin: true @@ -3897,6 +4274,12 @@ packages: hasBin: true dev: true + /nanoid/3.3.6: + resolution: {integrity: sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + dev: true + /natural-compare/1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} dev: true @@ -3958,7 +4341,6 @@ packages: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} dependencies: boolbase: 1.0.0 - dev: true /nwsapi/2.2.2: resolution: {integrity: sha512-90yv+6538zuvUMnN+zCr8LuV6bPFdq50304114vJYJ8RDyK8D5O9Phpbd6SZWgI7PwzmmfN1upeOJlvybDSgCw==} @@ -4274,6 +4656,15 @@ packages: source-map-js: 1.0.2 dev: true + /postcss/8.4.23: + resolution: {integrity: sha512-bQ3qMcpF6A/YjR55xtoTr0jGOlnPOKAIMdOWiv0EIT6HVPEaJiJB4NLljSbiHoC2RX7DN5Uvjtpbg1NPdwv1oA==} + engines: {node: ^10 || ^12 || >=14} + dependencies: + nanoid: 3.3.6 + picocolors: 1.0.0 + source-map-js: 1.0.2 + dev: true + /preact/10.11.3: resolution: {integrity: sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==} dev: true @@ -4545,11 +4936,23 @@ packages: lowercase-keys: 3.0.0 dev: true + /restore-cursor/4.0.0: + resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + dev: true + /reusify/1.0.4: resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} dev: true + /rfdc/1.3.0: + resolution: {integrity: sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==} + dev: true + /rimraf/2.4.5: resolution: {integrity: sha512-J5xnxTyqaiw06JjMftq7L9ouA448dw/E7dKghkP9WpKNuwmARNNg+Gk8/u5ryb9N/Yo2+z3MCwuqFK/+qPOPfQ==} hasBin: true @@ -4589,6 +4992,14 @@ packages: fsevents: 2.3.2 dev: true + /rollup/3.21.6: + resolution: {integrity: sha512-SXIICxvxQxR3D4dp/3LDHZIJPC8a4anKMHd4E3Jiz2/JnY+2bEjqrOokAauc5ShGVNFHlEFjBXAXlaxkJqIqSg==} + engines: {node: '>=14.18.0', npm: '>=8.0.0'} + hasBin: true + optionalDependencies: + fsevents: 2.3.2 + dev: true + /rollup/3.9.1: resolution: {integrity: sha512-GswCYHXftN8ZKGVgQhTFUJB/NBXxrRGgO2NCy6E8s1rwEJ4Q9/VttNqcYfEvx4dTo4j58YqdC3OVztPzlKSX8w==} engines: {node: '>=14.18.0', npm: '>=8.0.0'} @@ -4694,8 +5105,8 @@ packages: resolution: {integrity: sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==} dev: true - /shiki/0.14.1: - resolution: {integrity: sha512-+Jz4nBkCBe0mEDqo1eKRcCdjRtrCjozmcbTUjbPTX7OOJfEbTZzlUWlZtGe3Gb5oV1/jnojhG//YZc3rs9zSEw==} + /shiki/0.14.2: + resolution: {integrity: sha512-ltSZlSLOuSY0M0Y75KA+ieRaZ0Trf5Wl3gutE7jzLuIcWxLp5i/uEnLoQWNvgKXQ5OMpGkJnVMRLAuzjc0LJ2A==} dependencies: ansi-sequence-parser: 1.1.0 jsonc-parser: 3.2.0 @@ -5272,7 +5683,6 @@ packages: /uhyphen/0.2.0: resolution: {integrity: sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==} - dev: true /unique-string/3.0.0: resolution: {integrity: sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ==} @@ -5614,7 +6024,7 @@ packages: fsevents: 2.3.2 dev: true - /vite/4.1.4: + /vite/4.1.4_@types+node@18.11.9: resolution: {integrity: sha512-3knk/HsbSTKEin43zHu7jTwYWv81f8kgAL99G5NWBcA1LKvtvcVAC4JjBH1arBunO9kQka+1oGbrMKOjk4ZrBg==} engines: {node: ^14.18.0 || >=16.0.0} hasBin: true @@ -5639,6 +6049,7 @@ packages: terser: optional: true dependencies: + '@types/node': 18.11.9 esbuild: 0.16.15 postcss: 8.4.21 resolve: 1.22.1 @@ -5647,8 +6058,8 @@ packages: fsevents: 2.3.2 dev: true - /vite/4.1.4_@types+node@18.11.9: - resolution: {integrity: sha512-3knk/HsbSTKEin43zHu7jTwYWv81f8kgAL99G5NWBcA1LKvtvcVAC4JjBH1arBunO9kQka+1oGbrMKOjk4ZrBg==} + /vite/4.3.5: + resolution: {integrity: sha512-0gEnL9wiRFxgz40o/i/eTBwm+NEbpUeTWhzKrZDSdKm6nplj+z4lKz8ANDgildxHm47Vg8EUia0aicKbawUVVA==} engines: {node: ^14.18.0 || >=16.0.0} hasBin: true peerDependencies: @@ -5672,27 +6083,27 @@ packages: terser: optional: true dependencies: - '@types/node': 18.11.9 - esbuild: 0.16.15 - postcss: 8.4.21 - resolve: 1.22.1 - rollup: 3.18.0 + esbuild: 0.17.18 + postcss: 8.4.23 + rollup: 3.21.6 optionalDependencies: fsevents: 2.3.2 dev: true - /vitepress/1.0.0-alpha.49: - resolution: {integrity: sha512-3nUZJow4qL8NHRWYatqqVj45AJDxWst/TuOj+IbQRhxesEswa+Fpwayj9/FxzRzBl665fuiG5y+QeVhOeUm0OA==} + /vitepress/1.0.0-alpha.75_rurrd674gzxmsn4o6mrapg6sje: + resolution: {integrity: sha512-twpPZ/6UnDR8X0Nmj767KwKhXlTQQM9V/J1i2BP9ryO29/w4hpxBfEum6nvfpNhJ4H3h+cIhwzAK/e9crZ6HEQ==} hasBin: true dependencies: - '@docsearch/css': 3.3.3 - '@docsearch/js': 3.3.3 - '@vitejs/plugin-vue': 4.0.0_vite@4.1.4+vue@3.2.47 + '@docsearch/css': 3.3.4 + '@docsearch/js': 3.3.4_rurrd674gzxmsn4o6mrapg6sje + '@vitejs/plugin-vue': 4.2.1_vite@4.3.5+vue@3.2.47 '@vue/devtools-api': 6.5.0 - '@vueuse/core': 9.13.0_vue@3.2.47 + '@vueuse/core': 10.1.2_vue@3.2.47 body-scroll-lock: 4.0.0-beta.0 - shiki: 0.14.1 - vite: 4.1.4 + mark.js: 8.11.1 + minisearch: 6.0.1 + shiki: 0.14.2 + vite: 4.3.5 vue: 3.2.47 transitivePeerDependencies: - '@algolia/client-search' @@ -5966,8 +6377,8 @@ packages: resolution: {integrity: sha512-AFbieoL7a5LMqcnOF04ji+rpXadgOXnZsxQr//r83kLPr7biP7am3g9zbaZIaBGwBRWeSvoMD4mgPdX3e4NWBg==} dev: true - /vue-demi/0.13.11_vue@3.2.47: - resolution: {integrity: sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==} + /vue-demi/0.14.1_vue@3.2.47: + resolution: {integrity: sha512-rt+yuCtXvscYot9SQQj3WKZJVSriPNqVkpVBNEHPzSgBv7QIYzsS410VqVgvx8f9AAPgjg+XPKvmV3vOqqkJQQ==} engines: {node: '>=12'} hasBin: true requiresBuild: true @@ -6166,6 +6577,15 @@ packages: strip-ansi: 7.0.1 dev: true + /wrap-ansi/8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + dependencies: + ansi-styles: 6.2.1 + string-width: 5.1.2 + strip-ansi: 7.0.1 + dev: true + /wrappy/1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} dev: true