diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..e6a8276 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,3 @@ +dist +node_modules +public diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..09c2e42 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,3 @@ +{ + "extends": "@antfu" +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..453b76d --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +.DS_Store +.idea/ +.vite-ssg-dist +.vite-ssg-temp +*.crx +*.local +*.log +*.pem +*.xpi +*.zip +dist +dist-ssr +extension/manifest.json +node_modules +src/auto-imports.d.ts +src/components.d.ts +.eslintcache diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..009aa06 --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +shamefully-hoist=true +auto-install-peers=true diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..7a32c13 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,9 @@ +{ + "recommendations": [ + "vue.volar", + "antfu.iconify", + "antfu.unocss", + "dbaeumer.vscode-eslint", + "csstools.postcss" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..143bcfa --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "cSpell.words": ["Vitesse"], + "typescript.tsdk": "node_modules/typescript/lib", + "vite.autoStart": false, + "editor.codeActionsOnSave": { + "source.fixAll.eslint": true + }, + "files.associations": { + "*.css": "postcss" + } +} diff --git a/LICENSE.~1~ b/LICENSE.~1~ new file mode 100644 index 0000000..9b031a2 --- /dev/null +++ b/LICENSE.~1~ @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Anthony Fu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md.~1~ b/README.md.~1~ new file mode 100644 index 0000000..08614b9 --- /dev/null +++ b/README.md.~1~ @@ -0,0 +1,132 @@ +# WebExtension Vite Starter + +A [Vite](https://vitejs.dev/) powered WebExtension ([Chrome](https://developer.chrome.com/docs/extensions/reference/), [FireFox](https://addons.mozilla.org/en-US/developers/), etc.) starter template. + +

+Popup
+
+Options Page
+
+Inject Vue App into the Content Script
+ +

+ +## Features + +- ⚑️ **Instant HMR** - use **Vite** on dev (no more refresh!) +- πŸ₯ Vue 3 - Composition API, [` + + diff --git a/src/background/main.ts b/src/background/main.ts new file mode 100644 index 0000000..739d368 --- /dev/null +++ b/src/background/main.ts @@ -0,0 +1,54 @@ +import { onMessage, sendMessage } from 'webext-bridge/background' +import type { Tabs } from 'webextension-polyfill' + +// only on dev mode +if (import.meta.hot) { + // @ts-expect-error for background HMR + import('/@vite/client') + // load latest content script + import('./contentScriptHMR') +} + +browser.runtime.onInstalled.addListener((): void => { + // eslint-disable-next-line no-console + console.log('Extension installed') +}) + +let previousTabId = 0 + +// communication example: send previous tab title from background page +// see shim.d.ts for type declaration +browser.tabs.onActivated.addListener(async ({ tabId }) => { + if (!previousTabId) { + previousTabId = tabId + return + } + + let tab: Tabs.Tab + + try { + tab = await browser.tabs.get(previousTabId) + previousTabId = tabId + } + catch { + return + } + + // eslint-disable-next-line no-console + console.log('previous tab', tab) + sendMessage('tab-prev', { title: tab.title }, { context: 'content-script', tabId }) +}) + +onMessage('get-current-tab', async () => { + try { + const tab = await browser.tabs.get(previousTabId) + return { + title: tab?.title, + } + } + catch { + return { + title: undefined, + } + } +}) diff --git a/src/components/Logo.vue b/src/components/Logo.vue new file mode 100644 index 0000000..e135b9b --- /dev/null +++ b/src/components/Logo.vue @@ -0,0 +1,5 @@ + diff --git a/src/components/README.md b/src/components/README.md new file mode 100644 index 0000000..f9ed8ea --- /dev/null +++ b/src/components/README.md @@ -0,0 +1,11 @@ +## Components + +Components in this dir will be auto-registered and on-demand, powered by [`vite-plugin-components`](https://github.com/antfu/vite-plugin-components). + +Components can be shared in all views. + +### Icons + +You can use icons from almost any icon sets by the power of [Iconify](https://iconify.design/). + +It will only bundle the icons you use. Check out [vite-plugin-icons](https://github.com/antfu/vite-plugin-icons) for more details. diff --git a/src/components/SharedSubtitle.vue b/src/components/SharedSubtitle.vue new file mode 100644 index 0000000..94f633a --- /dev/null +++ b/src/components/SharedSubtitle.vue @@ -0,0 +1,5 @@ + diff --git a/src/components/__tests__/Logo.test.ts b/src/components/__tests__/Logo.test.ts new file mode 100644 index 0000000..a7d3436 --- /dev/null +++ b/src/components/__tests__/Logo.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' +import Logo from '../Logo.vue' + +describe('Logo Component', () => { + it('should render', () => { + const wrapper = mount(Logo) + + expect(wrapper.html()).toBeTruthy() + }) +}) diff --git a/src/composables/useStorageLocal.ts b/src/composables/useStorageLocal.ts new file mode 100644 index 0000000..50858b0 --- /dev/null +++ b/src/composables/useStorageLocal.ts @@ -0,0 +1,30 @@ +import { storage } from 'webextension-polyfill' +import type { + MaybeRef, + RemovableRef, + StorageLikeAsync, + UseStorageAsyncOptions, +} from '@vueuse/core' +import { + useStorageAsync, +} from '@vueuse/core' + +const storageLocal: StorageLikeAsync = { + removeItem(key: string) { + return storage.local.remove(key) + }, + + setItem(key: string, value: string) { + return storage.local.set({ [key]: value }) + }, + + async getItem(key: string) { + return (await storage.local.get(key))[key] + }, +} + +export const useStorageLocal = ( + key: string, + initialValue: MaybeRef, + options?: UseStorageAsyncOptions, +): RemovableRef => useStorageAsync(key, initialValue, storageLocal, options) diff --git a/src/contentScripts/index.ts b/src/contentScripts/index.ts new file mode 100644 index 0000000..a7ad027 --- /dev/null +++ b/src/contentScripts/index.ts @@ -0,0 +1,30 @@ +/* eslint-disable no-console */ +import { onMessage } from 'webext-bridge/content-script' +import { createApp } from 'vue' +import App from './views/App.vue' +import { setupApp } from '~/logic/common-setup' + +// Firefox `browser.tabs.executeScript()` requires scripts return a primitive value +(() => { + console.info('[vitesse-webext] Hello world from content script') + + // communication example: send previous tab title from background page + onMessage('tab-prev', ({ data }) => { + console.log(`[vitesse-webext] Navigate from page "${data.title}"`) + }) + + // mount component to context window + const container = document.createElement('div') + container.id = __NAME__ + const root = document.createElement('div') + const styleEl = document.createElement('link') + const shadowDOM = container.attachShadow?.({ mode: __DEV__ ? 'open' : 'closed' }) || container + styleEl.setAttribute('rel', 'stylesheet') + styleEl.setAttribute('href', browser.runtime.getURL('dist/contentScripts/style.css')) + shadowDOM.appendChild(styleEl) + shadowDOM.appendChild(root) + document.body.appendChild(container) + const app = createApp(App) + setupApp(app) + app.mount(root) +})() diff --git a/src/contentScripts/views/App.vue b/src/contentScripts/views/App.vue new file mode 100644 index 0000000..2b70716 --- /dev/null +++ b/src/contentScripts/views/App.vue @@ -0,0 +1,30 @@ + + + diff --git a/src/env.ts b/src/env.ts new file mode 100644 index 0000000..34f43c4 --- /dev/null +++ b/src/env.ts @@ -0,0 +1,14 @@ +const forbiddenProtocols = [ + 'chrome-extension://', + 'chrome-search://', + 'chrome://', + 'devtools://', + 'edge://', + 'https://chrome.google.com/webstore', +] + +export function isForbiddenUrl(url: string): boolean { + return forbiddenProtocols.some(protocol => url.startsWith(protocol)) +} + +export const isFirefox = navigator.userAgent.includes('Firefox') diff --git a/src/global.d.ts b/src/global.d.ts new file mode 100644 index 0000000..26e237c --- /dev/null +++ b/src/global.d.ts @@ -0,0 +1,8 @@ +declare const __DEV__: boolean +/** Extension name, defined in packageJson.name */ +declare const __NAME__: string + +declare module '*.vue' { + const component: any + export default component +} diff --git a/src/logic/common-setup.ts b/src/logic/common-setup.ts new file mode 100644 index 0000000..ea19e62 --- /dev/null +++ b/src/logic/common-setup.ts @@ -0,0 +1,15 @@ +import type { App } from 'vue' + +export function setupApp(app: App) { + // Inject a globally available `$app` object in template + app.config.globalProperties.$app = { + context: '', + } + + // Provide access to `app` in script setup with `const app = inject('app')` + app.provide('app', app.config.globalProperties.$app) + + // Here you can install additional plugins for all contexts: popup, options page and content-script. + // example: app.use(i18n) + // example excluding content-script context: if (context !== 'content-script') app.use(i18n) +} diff --git a/src/logic/index.ts b/src/logic/index.ts new file mode 100644 index 0000000..69b61ec --- /dev/null +++ b/src/logic/index.ts @@ -0,0 +1 @@ +export * from './storage' diff --git a/src/logic/storage.ts b/src/logic/storage.ts new file mode 100644 index 0000000..99329ab --- /dev/null +++ b/src/logic/storage.ts @@ -0,0 +1,3 @@ +import { useStorageLocal } from '~/composables/useStorageLocal' + +export const storageDemo = useStorageLocal('webext-demo', 'Storage Demo') diff --git a/src/manifest.ts b/src/manifest.ts new file mode 100644 index 0000000..5d3647d --- /dev/null +++ b/src/manifest.ts @@ -0,0 +1,72 @@ +import fs from 'fs-extra' +import type { Manifest } from 'webextension-polyfill' +import type PkgType from '../package.json' +import { isDev, port, r } from '../scripts/utils' + +export async function getManifest() { + const pkg = await fs.readJSON(r('package.json')) as typeof PkgType + + // update this file to update this manifest.json + // can also be conditional based on your need + const manifest: Manifest.WebExtensionManifest = { + manifest_version: 3, + name: pkg.displayName || pkg.name, + version: pkg.version, + description: pkg.description, + action: { + default_icon: './assets/icon-512.png', + default_popup: './dist/popup/index.html', + }, + options_ui: { + page: './dist/options/index.html', + open_in_tab: true, + }, + background: { + service_worker: './dist/background/index.mjs', + }, + icons: { + 16: './assets/icon-512.png', + 48: './assets/icon-512.png', + 128: './assets/icon-512.png', + }, + permissions: [ + 'tabs', + 'storage', + 'activeTab', + ], + host_permissions: ['*://*/*'], + content_scripts: [ + { + matches: [ + '', + ], + js: [ + 'dist/contentScripts/index.global.js', + ], + }, + ], + web_accessible_resources: [ + { + resources: ['dist/contentScripts/style.css'], + matches: [''], + }, + ], + content_security_policy: { + extension_pages: isDev + // this is required on dev for Vite script to load + ? `script-src \'self\' http://localhost:${port}; object-src \'self\'` + : 'script-src \'self\'; object-src \'self\'', + }, + } + + // FIXME: not work in MV3 + if (isDev && false) { + // for content script, as browsers will cache them for each reload, + // we use a background script to always inject the latest version + // see src/background/contentScriptHMR.ts + delete manifest.content_scripts + manifest.permissions?.push('webNavigation') + } + + return manifest +} diff --git a/src/options/Options.vue b/src/options/Options.vue new file mode 100644 index 0000000..452a1d4 --- /dev/null +++ b/src/options/Options.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/options/index.html b/src/options/index.html new file mode 100644 index 0000000..dbd67cb --- /dev/null +++ b/src/options/index.html @@ -0,0 +1,12 @@ + + + + + + Options + + +
+ + + diff --git a/src/options/main.ts b/src/options/main.ts new file mode 100644 index 0000000..c5aa99c --- /dev/null +++ b/src/options/main.ts @@ -0,0 +1,8 @@ +import { createApp } from 'vue' +import App from './Options.vue' +import { setupApp } from '~/logic/common-setup' +import '../styles' + +const app = createApp(App) +setupApp(app) +app.mount('#app') diff --git a/src/popup/Popup.vue b/src/popup/Popup.vue new file mode 100644 index 0000000..5b6b5be --- /dev/null +++ b/src/popup/Popup.vue @@ -0,0 +1,22 @@ + + + diff --git a/src/popup/index.html b/src/popup/index.html new file mode 100644 index 0000000..032a15d --- /dev/null +++ b/src/popup/index.html @@ -0,0 +1,12 @@ + + + + + + Popup + + +
+ + + diff --git a/src/popup/main.ts b/src/popup/main.ts new file mode 100644 index 0000000..8af5fbc --- /dev/null +++ b/src/popup/main.ts @@ -0,0 +1,8 @@ +import { createApp } from 'vue' +import App from './Popup.vue' +import { setupApp } from '~/logic/common-setup' +import '../styles' + +const app = createApp(App) +setupApp(app) +app.mount('#app') diff --git a/src/styles/index.ts b/src/styles/index.ts new file mode 100644 index 0000000..ddd5dc8 --- /dev/null +++ b/src/styles/index.ts @@ -0,0 +1,3 @@ +import '@unocss/reset/tailwind.css' +import './main.css' +import 'uno.css' diff --git a/src/styles/main.css b/src/styles/main.css new file mode 100755 index 0000000..716ffbb --- /dev/null +++ b/src/styles/main.css @@ -0,0 +1,20 @@ +html, +body, +#app { + margin: 0; + padding: 0; +} + +.btn { + @apply px-4 py-1 rounded inline-block + bg-teal-600 text-white cursor-pointer + hover:bg-teal-700 + disabled:cursor-default disabled:bg-gray-600 disabled:opacity-50; +} + +.icon-btn { + @apply inline-block cursor-pointer select-none + opacity-75 transition duration-200 ease-in-out + hover:opacity-100 hover:text-teal-600; + font-size: 0.9em; +} diff --git a/src/tests/demo.spec.ts b/src/tests/demo.spec.ts new file mode 100644 index 0000000..dcaca6e --- /dev/null +++ b/src/tests/demo.spec.ts @@ -0,0 +1,7 @@ +import { describe, expect, it } from 'vitest' + +describe('Demo', () => { + it('should work', () => { + expect(1 + 1).toBe(2) + }) +}) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..4802983 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "module": "ESNext", + "target": "es2016", + "lib": ["DOM", "ESNext"], + "strict": true, + "esModuleInterop": true, + "incremental": false, + "skipLibCheck": true, + "jsx": "preserve", + "moduleResolution": "node", + "resolveJsonModule": true, + "noUnusedLocals": true, + "forceConsistentCasingInFileNames": true, + "types": [ + "vite/client" + ], + "paths": { + "~/*": ["src/*"] + } + }, + "exclude": ["dist", "node_modules"] +} diff --git a/unocss.config.ts b/unocss.config.ts new file mode 100644 index 0000000..dcdb3ba --- /dev/null +++ b/unocss.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'unocss/vite' +import { presetAttributify, presetIcons, presetUno, transformerDirectives } from 'unocss' + +export default defineConfig({ + presets: [ + presetUno(), + presetAttributify(), + presetIcons(), + ], + transformers: [ + transformerDirectives(), + ], +}) diff --git a/vite.config.background.ts b/vite.config.background.ts new file mode 100644 index 0000000..1da32e9 --- /dev/null +++ b/vite.config.background.ts @@ -0,0 +1,36 @@ +import { defineConfig } from 'vite' +import { sharedConfig } from './vite.config' +import { isDev, r } from './scripts/utils' +import packageJson from './package.json' + +// bundling the content script using Vite +export default defineConfig({ + ...sharedConfig, + define: { + '__DEV__': isDev, + '__NAME__': JSON.stringify(packageJson.name), + // https://github.com/vitejs/vite/issues/9320 + // https://github.com/vitejs/vite/issues/9186 + 'process.env.NODE_ENV': JSON.stringify(isDev ? 'development' : 'production'), + }, + build: { + watch: isDev + ? {} + : undefined, + outDir: r('extension/dist/background'), + cssCodeSplit: false, + emptyOutDir: false, + sourcemap: isDev ? 'inline' : false, + lib: { + entry: r('src/background/main.ts'), + name: packageJson.name, + formats: ['iife'], + }, + rollupOptions: { + output: { + entryFileNames: 'index.mjs', + extend: true, + }, + }, + }, +}) diff --git a/vite.config.content.ts b/vite.config.content.ts new file mode 100644 index 0000000..f1e2871 --- /dev/null +++ b/vite.config.content.ts @@ -0,0 +1,36 @@ +import { defineConfig } from 'vite' +import { sharedConfig } from './vite.config' +import { isDev, r } from './scripts/utils' +import packageJson from './package.json' + +// bundling the content script using Vite +export default defineConfig({ + ...sharedConfig, + define: { + '__DEV__': isDev, + '__NAME__': JSON.stringify(packageJson.name), + // https://github.com/vitejs/vite/issues/9320 + // https://github.com/vitejs/vite/issues/9186 + 'process.env.NODE_ENV': JSON.stringify(isDev ? 'development' : 'production'), + }, + build: { + watch: isDev + ? {} + : undefined, + outDir: r('extension/dist/contentScripts'), + cssCodeSplit: false, + emptyOutDir: false, + sourcemap: isDev ? 'inline' : false, + lib: { + entry: r('src/contentScripts/index.ts'), + name: packageJson.name, + formats: ['iife'], + }, + rollupOptions: { + output: { + entryFileNames: 'index.global.js', + extend: true, + }, + }, + }, +}) diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..f6a3b2f --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,113 @@ +/// + +import { dirname, relative } from 'node:path' +import type { UserConfig } from 'vite' +import { defineConfig } from 'vite' +import Vue from '@vitejs/plugin-vue' +import Icons from 'unplugin-icons/vite' +import IconsResolver from 'unplugin-icons/resolver' +import Components from 'unplugin-vue-components/vite' +import AutoImport from 'unplugin-auto-import/vite' +import UnoCSS from 'unocss/vite' +import { isDev, port, r } from './scripts/utils' +import packageJson from './package.json' + +export const sharedConfig: UserConfig = { + root: r('src'), + resolve: { + alias: { + '~/': `${r('src')}/`, + }, + }, + define: { + __DEV__: isDev, + __NAME__: JSON.stringify(packageJson.name), + }, + plugins: [ + Vue(), + + AutoImport({ + imports: [ + 'vue', + { + 'webextension-polyfill': [ + ['*', 'browser'], + ], + }, + ], + dts: r('src/auto-imports.d.ts'), + }), + + // https://github.com/antfu/unplugin-vue-components + Components({ + dirs: [r('src/components')], + // generate `components.d.ts` for ts support with Volar + dts: r('src/components.d.ts'), + resolvers: [ + // auto import icons + IconsResolver({ + componentPrefix: '', + }), + ], + }), + + // https://github.com/antfu/unplugin-icons + Icons(), + + // https://github.com/unocss/unocss + UnoCSS(), + + // rewrite assets to use relative path + { + name: 'assets-rewrite', + enforce: 'post', + apply: 'build', + transformIndexHtml(html, { path }) { + return html.replace(/"\/assets\//g, `"${relative(dirname(path), '/assets')}/`) + }, + }, + ], + optimizeDeps: { + include: [ + 'vue', + '@vueuse/core', + 'webextension-polyfill', + ], + exclude: [ + 'vue-demi', + ], + }, +} + +export default defineConfig(({ command }) => ({ + ...sharedConfig, + base: command === 'serve' ? `http://localhost:${port}/` : '/dist/', + server: { + port, + hmr: { + host: 'localhost', + }, + }, + build: { + watch: isDev + ? {} + : undefined, + outDir: r('extension/dist'), + emptyOutDir: false, + sourcemap: isDev ? 'inline' : false, + // https://developer.chrome.com/docs/webstore/program_policies/#:~:text=Code%20Readability%20Requirements + terserOptions: { + mangle: false, + }, + rollupOptions: { + input: { + options: r('src/options/index.html'), + popup: r('src/popup/index.html'), + }, + }, + }, + test: { + globals: true, + environment: 'jsdom', + }, +}))