Initial Repo

This commit is contained in:
johnathan 2025-04-12 17:26:36 +08:00
commit bdc3d0d512
51 changed files with 8668 additions and 0 deletions

17
.gitignore vendored Normal file
View File

@ -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

1
.husky/pre-commit Normal file
View File

@ -0,0 +1 @@
pnpm exec lint-staged

2
.npmrc Normal file
View File

@ -0,0 +1,2 @@
shamefully-hoist=true
auto-install-peers=true

3
.prettierignore Normal file
View File

@ -0,0 +1,3 @@
# Ignore artifacts:
build
coverage

3
.prettierrc Normal file
View File

@ -0,0 +1,3 @@
{
"singleQuote": true
}

9
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,9 @@
{
"recommendations": [
"vue.volar"
// "antfu.iconify",
// "antfu.unocss",
// "dbaeumer.vscode-eslint",
// "csstools.postcss"
]
}

12
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,12 @@
{
"cSpell.words": ["Vitesse"],
"typescript.tsdk": "node_modules/typescript/lib",
"vite.autoStart": false,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"files.associations": {
"*.css": "postcss"
},
"prettier.tabWidth": 2
}

21
LICENSE Normal file
View File

@ -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.

138
README_Template.md Normal file
View File

@ -0,0 +1,138 @@
# 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.
<p align="center">
<sub>Popup</sub><br/>
<img width="655" src="https://user-images.githubusercontent.com/11247099/126741643-813b3773-17ff-4281-9737-f319e00feddc.png"><br/>
<sub>Options Page</sub><br/>
<img width="655" src="https://user-images.githubusercontent.com/11247099/126741653-43125b62-6578-4452-83a7-bee19be2eaa2.png"><br/>
<sub>Inject Vue App into the Content Script</sub><br/>
<img src="https://user-images.githubusercontent.com/11247099/130695439-52418cf0-e186-4085-8e19-23fe808a274e.png">
</p>
## Features
- ⚡️ **Instant HMR** - use **Vite** on dev (no more refresh!)
- 🥝 Vue 3 - Composition API, [`<script setup>` syntax](https://github.com/vuejs/rfcs/blob/master/active-rfcs/0040-script-setup.md) and more!
- 💬 Effortless communications - powered by [`webext-bridge`](https://github.com/antfu/webext-bridge) and [VueUse](https://github.com/antfu/vueuse) storage
- 🌈 [UnoCSS](https://github.com/unocss/unocss) - The instant on-demand Atomic CSS engine.
- 🦾 [TypeScript](https://www.typescriptlang.org/) - type safe
- 📦 [Components auto importing](./src/components)
- 🌟 [Icons](./src/components) - Access to icons from any iconset directly
- 🖥 Content Script - Use Vue even in content script
- 🌍 WebExtension - isomorphic extension for Chrome, Firefox, and others
- 📃 Dynamic `manifest.json` with full type support
## Pre-packed
### WebExtension Libraries
- [`webextension-polyfill`](https://github.com/mozilla/webextension-polyfill) - WebExtension browser API Polyfill with types
- [`webext-bridge`](https://github.com/antfu/webext-bridge) - effortlessly communication between contexts
### Vite Plugins
- [`unplugin-auto-import`](https://github.com/antfu/unplugin-auto-import) - Directly use `browser` and Vue Composition API without importing
- [`unplugin-vue-components`](https://github.com/antfu/vite-plugin-components) - components auto import
- [`unplugin-icons`](https://github.com/antfu/unplugin-icons) - icons as components
- [Iconify](https://iconify.design) - use icons from any icon sets [🔍Icônes](https://icones.netlify.app/)
### Vue Plugins
- [VueUse](https://github.com/antfu/vueuse) - collection of useful composition APIs
### UI Frameworks
- [UnoCSS](https://github.com/unocss/unocss) - the instant on-demand Atomic CSS engine
### Coding Style
- Use Composition API with [`<script setup>` SFC syntax](https://github.com/vuejs/rfcs/pull/227)
- [ESLint](https://eslint.org/) with [@antfu/eslint-config](https://github.com/antfu/eslint-config), single quotes, no semi
### Dev tools
- [TypeScript](https://www.typescriptlang.org/)
- [pnpm](https://pnpm.js.org/) - fast, disk space efficient package manager
- [esno](https://github.com/antfu/esno) - TypeScript / ESNext node runtime powered by esbuild
- [npm-run-all](https://github.com/mysticatea/npm-run-all) - Run multiple npm-scripts in parallel or sequential
- [web-ext](https://github.com/mozilla/web-ext) - Streamlined experience for developing web extensions
## Use the Template
### GitHub Template
[Create a repo from this template on GitHub](https://github.com/antfu/vitesse-webext/generate).
### Clone to local
If you prefer to do it manually with the cleaner git history
> If you don't have pnpm installed, run: npm install -g pnpm
```bash
npx degit antfu/vitesse-webext my-webext
cd my-webext
pnpm i
```
## Usage
### Folders
- `src` - main source.
- `contentScript` - scripts and components to be injected as `content_script`
- `background` - scripts for background.
- `components` - auto-imported Vue components that are shared in popup and options page.
- `styles` - styles shared in popup and options page
- `assets` - assets used in Vue components
- `manifest.ts` - manifest for the extension.
- `extension` - extension package root.
- `assets` - static assets (mainly for `manifest.json`).
- `dist` - built files, also serve stub entry for Vite on development.
- `scripts` - development and bundling helper scripts.
### Development
```bash
pnpm dev
```
Then **load extension in browser with the `extension/` folder**.
For Firefox developers, you can run the following command instead:
```bash
pnpm dev-firefox
```
`web-ext` auto reload the extension when `extension/` files changed.
> While Vite handles HMR automatically in the most of the case, [Extensions Reloader](https://chrome.google.com/webstore/detail/fimgfedafeadlieiabdeeaodndnlbhid) is still recommended for cleaner hard reloading.
## Using Gitpod
If you have a web browser, you can get a fully pre-configured development environment with one click:
[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/antfu/vitesse-webext)
### Build
To build the extension, run
```bash
pnpm build
```
And then pack files under `extension`, you can upload `extension.crx` or `extension.xpi` to appropriate extension store.
## Credits
[![Volta](https://user-images.githubusercontent.com/904724/195351818-9e826ea9-12a0-4b06-8274-352743cd2047.png)](https://volta.net)
This template is originally made for the [volta.net](https://volta.net) browser extension.
## Variations
This is a variant of [Vitesse](https://github.com/antfu/vitesse), check out the [full variations list](https://github.com/antfu/vitesse#variations).

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@ -0,0 +1,3 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M26.6667 1.66667H24V7H8V9.66667H5.33333V20.3333H8V23H10.6667V28.3333H21.3333V25.6667H26.6667V23H21.3333V20.3333H26.6667V17.6667H21.3333V15H10.6667V20.3333H8V9.66667H24V7H26.6667V1.66667ZM18.6667 25.6667H13.3333V17.6667H18.6667V25.6667Z" fill="#888888"/>
</svg>

After

Width:  |  Height:  |  Size: 366 B

10
modules.d.ts vendored Normal file
View File

@ -0,0 +1,10 @@
declare module 'vue' {
interface ComponentCustomProperties {
$app: {
context: string;
};
}
}
// https://stackoverflow.com/a/64189046/479957
export {};

68
package.json Normal file
View File

@ -0,0 +1,68 @@
{
"name": "azon-seeker",
"displayName": "Azon Seeker",
"version": "0.0.1",
"private": true,
"description": "Starter modify by honestfox101",
"scripts": {
"dev": "npm run clear && cross-env NODE_ENV=development run-p dev:*",
"dev-firefox": "npm run clear && cross-env NODE_ENV=development EXTENSION=firefox run-p dev:*",
"dev:prepare": "esno scripts/prepare.ts",
"dev:background": "npm run build:background -- --mode development",
"dev:web": "vite",
"dev:js": "npm run build:js -- --mode development",
"build": "cross-env NODE_ENV=production run-s clear build:web build:prepare build:background build:js",
"build:prepare": "esno scripts/prepare.ts",
"build:background": "vite build --config vite.config.background.mts",
"build:web": "vite build",
"build:js": "vite build --config vite.config.content.mts",
"pack": "cross-env NODE_ENV=production run-p pack:*",
"pack:zip": "rimraf extension.zip && jszip-cli add extension/* -o ./extension.zip",
"pack:crx": "crx pack extension -o ./extension.crx",
"pack:xpi": "cross-env WEB_EXT_ARTIFACTS_DIR=./ web-ext build --source-dir ./extension --filename extension.xpi --overwrite-dest",
"start:chromium": "web-ext run --source-dir ./extension --target=chromium",
"start:firefox": "web-ext run --source-dir ./extension --target=firefox-desktop",
"clear": "rimraf --glob extension/dist extension/manifest.json extension.*",
"test": "vitest test",
"typecheck": "tsc --noEmit",
"prepare": "husky"
},
"devDependencies": {
"@ffflorian/jszip-cli": "^3.8.2",
"@iconify/json": "^2.2.293",
"@types/fs-extra": "^11.0.4",
"@types/node": "^22.10.5",
"@types/webextension-polyfill": "^0.12.1",
"@vitejs/plugin-vue": "^5.2.1",
"@vue/test-utils": "^2.4.6",
"@vueuse/core": "^12.3.0",
"chokidar": "^4.0.3",
"cross-env": "^7.0.3",
"crx": "^5.0.1",
"esno": "^4.8.0",
"fs-extra": "^11.2.0",
"husky": "^9.1.7",
"jsdom": "^26.0.0",
"kolorist": "^1.8.0",
"lint-staged": "^15.5.0",
"naive-ui": "^2.41.0",
"npm-run-all": "^4.1.5",
"prettier": "3.5.3",
"rimraf": "^6.0.1",
"sass-embedded": "^1.86.2",
"typescript": "^5.8.2",
"unplugin-auto-import": "^19.1.2",
"unplugin-icons": "^22.1.0",
"unplugin-vue-components": "^28.4.1",
"vite": "^6.2.4",
"vitest": "^3.1.1",
"vue": "^3.5.13",
"vue-demi": "^0.14.10",
"web-ext": "^8.5.0",
"webext-bridge": "^6.0.1",
"webextension-polyfill": "^0.12.0"
},
"lint-staged": {
"**/*": "prettier --write --ignore-unknown"
}
}

7363
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

12
scripts/manifest.ts Normal file
View File

@ -0,0 +1,12 @@
import fs from 'fs-extra';
import { getManifest } from '../src/manifest';
import { log, r } from './utils';
export async function writeManifest() {
await fs.writeJSON(r('extension/manifest.json'), await getManifest(), {
spaces: 2,
});
log('PRE', 'write manifest.json');
}
writeManifest();

41
scripts/prepare.ts Normal file
View File

@ -0,0 +1,41 @@
// generate stub index.html files for dev entry
import { execSync } from 'node:child_process';
import fs from 'fs-extra';
import chokidar from 'chokidar';
import { isDev, log, port, r } from './utils';
/**
* Stub index.html to use Vite in development
*/
async function stubIndexHtml() {
const views = ['options', 'popup', 'sidepanel'];
for (const view of views) {
await fs.ensureDir(r(`extension/dist/${view}`));
let data = await fs.readFile(r(`src/${view}/index.html`), 'utf-8');
data = data
.replace('"./main.ts"', `"http://localhost:${port}/${view}/main.ts"`)
.replace(
'<div id="app"></div>',
'<div id="app">Vite server did not start</div>',
);
await fs.writeFile(r(`extension/dist/${view}/index.html`), data, 'utf-8');
log('PRE', `stub ${view}`);
}
}
function writeManifest() {
execSync('npx esno ./scripts/manifest.ts', { stdio: 'inherit' });
}
writeManifest();
if (isDev) {
stubIndexHtml();
chokidar.watch(r('src/**/*.html')).on('change', () => {
stubIndexHtml();
});
chokidar.watch([r('src/manifest.ts'), r('package.json')]).on('change', () => {
writeManifest();
});
}

12
scripts/utils.ts Normal file
View File

@ -0,0 +1,12 @@
import { resolve } from 'node:path';
import process from 'node:process';
import { bgCyan, black } from 'kolorist';
export const port = Number(process.env.PORT || '') || 3303;
export const r = (...args: string[]) => resolve(__dirname, '..', ...args);
export const isDev = process.env.NODE_ENV !== 'production';
export const isFirefox = process.env.EXTENSION === 'firefox';
export function log(name: string, message: string) {
console.log(black(bgCyan(` ${name} `)), message);
}

8
shim.d.ts vendored Normal file
View File

@ -0,0 +1,8 @@
import type { ProtocolWithReturn } from 'webext-bridge';
declare module 'webext-bridge' {
export interface ProtocolMap {
// define message protocol types
// see https://github.com/antfu/webext-bridge#type-safe-protocols
}
}

3
src/assets/logo.svg Normal file
View File

@ -0,0 +1,3 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M26.6667 1.66667H24V7H8V9.66667H5.33333V20.3333H8V23H10.6667V28.3333H21.3333V25.6667H26.6667V23H21.3333V20.3333H26.6667V17.6667H21.3333V15H10.6667V20.3333H8V9.66667H24V7H26.6667V1.66667ZM18.6667 25.6667H13.3333V17.6667H18.6667V25.6667Z" fill="#69717d"/>
</svg>

After

Width:  |  Height:  |  Size: 366 B

View File

@ -0,0 +1,18 @@
import { isFirefox, isForbiddenUrl } from '~/env';
// Firefox fetch files from cache instead of reloading changes from disk,
// hmr will not work as Chromium based browser
browser.webNavigation.onCommitted.addListener(({ tabId, frameId, url }) => {
// Filter out non main window events.
if (frameId !== 0) return;
if (isForbiddenUrl(url)) return;
// inject the latest scripts
browser.tabs
.executeScript(tabId, {
file: `${isFirefox ? '' : '.'}/dist/contentScripts/index.global.js`,
runAt: 'document_end',
})
.catch((error) => console.error(error));
});

23
src/background/main.ts Normal file
View File

@ -0,0 +1,23 @@
// only on dev mode
if (import.meta.hot) {
// @ts-expect-error for background HMR
import('/@vite/client');
// load latest content script
import('./contentScriptHMR');
}
// remove or turn this off if you don't use side panel
const USE_SIDE_PANEL = true;
// to toggle the sidepanel with the action button in chromium:
if (USE_SIDE_PANEL) {
// @ts-expect-error missing types
browser.sidePanel
.setPanelBehavior({ openPanelOnActionClick: true })
.catch((error: unknown) => console.error(error));
}
browser.runtime.onInstalled.addListener((): void => {
// eslint-disable-next-line no-console
console.log('Azon Seeker installed');
});

19
src/components/Logo.vue Normal file
View File

@ -0,0 +1,19 @@
<template>
<a
class="icon"
rel="noreferrer"
href="https://github.com/antfu/vitesse-webext"
target="_blank"
title="GitHub"
>
<pixelarticons-power width="3rem" height="3rem" />
</a>
</template>
<style lang="scss" scoped>
.icon {
display: block;
color: #333;
fill: currentColor;
}
</style>

11
src/components/README.md Normal file
View File

@ -0,0 +1,11 @@
## Components
Components in this dir will be auto-registered and on-demand, powered by [unplugin-vue-components](https://github.com/unplugin/unplugin-vue-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 [unplugin-icons](https://github.com/unplugin/unplugin-icons) for more details.

View File

@ -0,0 +1,10 @@
<template>
<p class="shared-subtitle">This is the {{ $app.context }} page</p>
</template>
<style lang="scss" scoped>
.shared-subtitle {
font-size: 1.5rem;
color: #333;
}
</style>

View File

@ -0,0 +1,155 @@
import { StorageSerializers } from '@vueuse/core';
import { pausableWatch, toValue, tryOnScopeDispose } from '@vueuse/shared';
import { ref, shallowRef } from 'vue-demi';
import { storage } from 'webextension-polyfill';
import type { StorageLikeAsync, UseStorageAsyncOptions } from '@vueuse/core';
import type { MaybeRefOrGetter, RemovableRef } from '@vueuse/shared';
import type { Ref } from 'vue-demi';
import type { Storage } from 'webextension-polyfill';
export type WebExtensionStorageOptions<T> = UseStorageAsyncOptions<T>;
// https://github.com/vueuse/vueuse/blob/658444bf9f8b96118dbd06eba411bb6639e24e88/packages/core/useStorage/guess.ts
export function guessSerializerType(rawInit: unknown) {
return rawInit == null
? 'any'
: rawInit instanceof Set
? 'set'
: rawInit instanceof Map
? 'map'
: rawInit instanceof Date
? 'date'
: typeof rawInit === 'boolean'
? 'boolean'
: typeof rawInit === 'string'
? 'string'
: typeof rawInit === 'object'
? 'object'
: Number.isNaN(rawInit)
? 'any'
: 'number';
}
const storageInterface: StorageLikeAsync = {
removeItem(key: string) {
return storage.local.remove(key);
},
setItem(key: string, value: string) {
return storage.local.set({ [key]: value });
},
async getItem(key: string) {
const storedData = await storage.local.get(key);
return storedData[key] as string;
},
};
/**
* https://github.com/vueuse/vueuse/blob/658444bf9f8b96118dbd06eba411bb6639e24e88/packages/core/useStorageAsync/index.ts
*
* @param key
* @param initialValue
* @param options
*/
export function useWebExtensionStorage<T>(
key: string,
initialValue: MaybeRefOrGetter<T>,
options: WebExtensionStorageOptions<T> = {},
): RemovableRef<T> {
const {
flush = 'pre',
deep = true,
listenToStorageChanges = true,
writeDefaults = true,
mergeDefaults = false,
shallow,
eventFilter,
onError = (e) => {
console.error(e);
},
} = options;
const rawInit: T = toValue(initialValue);
const type = guessSerializerType(rawInit);
const data = (shallow ? shallowRef : ref)(initialValue) as Ref<T>;
const serializer = options.serializer ?? StorageSerializers[type];
async function read(event?: { key: string; newValue: string | null }) {
if (event && event.key !== key) return;
try {
const rawValue = event
? event.newValue
: await storageInterface.getItem(key);
if (rawValue == null) {
data.value = rawInit;
if (writeDefaults && rawInit !== null)
await storageInterface.setItem(key, await serializer.write(rawInit));
} else if (mergeDefaults) {
const value = (await serializer.read(rawValue)) as T;
if (typeof mergeDefaults === 'function')
data.value = mergeDefaults(value, rawInit);
else if (type === 'object' && !Array.isArray(value))
data.value = {
...(rawInit as Record<keyof unknown, unknown>),
...(value as Record<keyof unknown, unknown>),
} as T;
else data.value = value;
} else {
data.value = (await serializer.read(rawValue)) as T;
}
} catch (error) {
onError(error);
}
}
void read();
async function write() {
try {
await (data.value == null
? storageInterface.removeItem(key)
: storageInterface.setItem(key, await serializer.write(data.value)));
} catch (error) {
onError(error);
}
}
const { pause: pauseWatch, resume: resumeWatch } = pausableWatch(
data,
write,
{
flush,
deep,
eventFilter,
},
);
if (listenToStorageChanges) {
const listener = async (changes: Record<string, Storage.StorageChange>) => {
try {
pauseWatch();
for (const [key, change] of Object.entries(changes)) {
await read({
key,
newValue: change.newValue as string | null,
});
}
} finally {
resumeWatch();
}
};
storage.onChanged.addListener(listener);
tryOnScopeDispose(() => {
storage.onChanged.removeListener(listener);
});
}
return data as RemovableRef<T>;
}

View File

@ -0,0 +1,36 @@
/* eslint-disable no-console */
import App from './views/App.vue';
import { setupApp } from '~/logic/common-setup';
// 是否挂在ContentScript Vue APP
const MOUNT_COMPONENT = false;
const mountComponent = () => {
// 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/index.css'),
);
shadowDOM.appendChild(styleEl);
shadowDOM.appendChild(root);
document.body.appendChild(container);
const app = createApp(App);
setupApp(app);
app.mount(root);
};
// Firefox `browser.tabs.executeScript()` requires scripts return a primitive value
(() => {
if (MOUNT_COMPONENT) {
mountComponent();
}
})();

View File

@ -0,0 +1,28 @@
<script setup lang="ts">
import { useToggle } from '@vueuse/core';
const [show, toggle] = useToggle(false);
</script>
<template>
<main class="injected-content">
<button @click="toggle()">
<pixelarticons-power />
</button>
</main>
</template>
<style lang="scss" scoped>
.injected-content {
position: fixed;
bottom: 0;
right: 0;
z-index: 9999;
}
button {
border-radius: 0.5rem;
padding: 0.5rem;
text-align: center;
}
</style>

14
src/env.ts Normal file
View File

@ -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');

10
src/global.d.ts vendored Normal file
View File

@ -0,0 +1,10 @@
/// <reference types="vite/client" />
declare const __DEV__: boolean;
/** Extension name, defined in packageJson.name */
declare const __NAME__: string;
declare module '*.vue' {
const component: any;
export default component;
}

15
src/logic/common-setup.ts Normal file
View File

@ -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)
}

1
src/logic/index.ts Normal file
View File

@ -0,0 +1 @@

View File

@ -0,0 +1,64 @@
class AmazonPageWorkerImpl implements AmazonPageWorker {
public async doSearch(keywords: string): Promise<string> {
const url = new URL('https://www.amazon.com/s');
url.searchParams.append('k', keywords);
const tab = await browser.tabs
.query({ active: true, currentWindow: true })
.then((tabs) => tabs[0]);
const currentUrl = new URL(tab.url!);
if (
currentUrl.hostname !== url.hostname ||
currentUrl.searchParams.get('k') !== keywords
) {
await browser.tabs.update(tab.id, { url: url.toString() });
}
return url.toString();
}
public async wanderSearchList(): Promise<void> {
const tab = await browser.tabs
.query({ active: true, currentWindow: true })
.then((tabs) => tabs[0]);
const results = await browser.scripting.executeScript({
target: { tabId: tab.id! },
func: async () => {
try {
await new Promise((resolve) =>
setTimeout(resolve, 500 + ~~(500 * Math.random())),
);
while (!document.querySelector('.s-pagination-strip')) {
window.scrollBy(0, ~~(Math.random() * 500) + 500);
await new Promise((resolve) => setTimeout(resolve, 10));
}
const nextButton =
document.querySelector<HTMLLinkElement>('.s-pagination-next');
if (
nextButton &&
!nextButton.classList.contains('s-pagination-disabled')
) {
await new Promise((resolve) =>
setTimeout(resolve, 500 + ~~(500 * Math.random())),
);
nextButton.click();
}
return true;
} catch (e) {
return false;
}
},
});
console.log('results', results);
return new Promise((resolve) => setTimeout(resolve, 1000));
}
}
class PageWorkerFactory {
public createAmazonPageWorker(): AmazonPageWorker {
return new AmazonPageWorkerImpl();
}
}
const pageWorkerFactory = new PageWorkerFactory();
export default pageWorkerFactory;

14
src/logic/page-worker/types.d.ts vendored Normal file
View File

@ -0,0 +1,14 @@
interface AmazonPageWorker {
/**
* Search for a list of items on Amazon
* @param keywords - The keywords to search for on Amazon.
* @returns A promise that resolves to a string representing the search URL.
*/
doSearch(keywords: string): Promise<string>;
/**
* Browsing item search page and collect links to those items.
* @param entryUrl - The URL of the Amazon search page to start from.
*/
wanderSearchList(): Promise<void>;
}

3
src/logic/storage.ts Normal file
View File

@ -0,0 +1,3 @@
import { useWebExtensionStorage } from '~/composables/useWebExtensionStorage';
export const keywords = useWebExtensionStorage<string>('keywords', '');

81
src/manifest.ts Normal file
View File

@ -0,0 +1,81 @@
import fs from 'fs-extra';
import type { Manifest } from 'webextension-polyfill';
import type PkgType from '../package.json';
import { isDev, isFirefox, 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: isFirefox
? {
scripts: ['dist/background/index.mjs'],
type: 'module',
}
: {
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', 'sidePanel', 'scripting'],
host_permissions: ['*://*/*'],
content_scripts: [
{
matches: ['<all_urls>'],
js: ['dist/contentScripts/index.global.js'],
},
],
web_accessible_resources: [
{
resources: ['dist/contentScripts/index.css'],
matches: ['<all_urls>'],
},
],
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'",
},
};
// add sidepanel
if (isFirefox) {
manifest.sidebar_action = {
default_panel: 'dist/sidepanel/index.html',
};
} else {
// the sidebar_action does not work for chromium based
(manifest as any).side_panel = {
default_path: 'dist/sidepanel/index.html',
};
}
// 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;
}

39
src/options/Options.vue Normal file
View File

@ -0,0 +1,39 @@
<script setup lang="ts">
import logo from '~/assets/logo.svg';
</script>
<template>
<main class="option-page">
<img :src="logo" class="" alt="extension icon" />
<div class="title">Options Page</div>
<SharedSubtitle />
<div class="footer">
Powered by Vite <pixelarticons-zap class="align-middle inline-block" />
</div>
</main>
</template>
<style lang="scss" scoped>
.option-page {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
background-color: #a3a8d4;
.title {
font-size: 2rem;
}
.input-field {
padding: 0.5rem;
border: 1px solid #333;
border-radius: 0.25rem;
}
.footer {
margin-top: 1rem;
}
}
</style>

12
src/options/index.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<base target="_blank" />
<title>Options</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="./main.ts"></script>
</body>
</html>

8
src/options/main.ts Normal file
View File

@ -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');

43
src/popup/Popup.vue Normal file
View File

@ -0,0 +1,43 @@
<script setup lang="ts">
function openOptionsPage() {
browser.runtime.openOptionsPage();
}
</script>
<template>
<main class="popup">
<Logo />
<div class="title">Popup</div>
<SharedSubtitle />
<button @click="openOptionsPage">Open Options</button>
</main>
</template>
<style lang="scss" scoped>
.popup {
min-width: 240px;
min-height: 300px;
padding: 1rem;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
.title {
font-size: 2rem;
}
button {
padding: 0.5rem 1rem;
margin-top: 1rem;
border: 1px solid #333;
border-radius: 0.25rem;
background-color: #fff;
cursor: pointer;
}
.footer {
margin-top: 1rem;
}
}
</style>

12
src/popup/index.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<base target="_blank" />
<title>Popup</title>
</head>
<body style="min-width: 100px">
<div id="app"></div>
<script type="module" src="./main.ts"></script>
</body>
</html>

7
src/popup/main.ts Normal file
View File

@ -0,0 +1,7 @@
import App from './Popup.vue';
import { setupApp } from '~/logic/common-setup';
import '../styles';
const app = createApp(App);
setupApp(app);
app.mount('#app');

View File

@ -0,0 +1,63 @@
<script setup lang="ts">
import { keywords } from '~/logic/storage';
import pageWorker from '~/logic/page-worker';
const onSearch = async () => {
if (keywords.value.trim() === '') {
return;
}
const worker = pageWorker.createAmazonPageWorker();
await worker.doSearch(keywords.value);
await worker.wanderSearchList();
};
</script>
<template>
<main class="side-panel">
<n-space>
<mdi-cat style="font-size: 60px; color: black" />
<h1>Azon Seeker</h1>
</n-space>
<n-space>
<n-input
v-model:value="keywords"
class="search-input-box"
autosize
size="large"
round
placeholder="请输入关键词"
/>
<n-button round size="large" @click="onSearch">搜索</n-button>
</n-space>
<div style="height: 10px"></div>
<n-card class="result-content-container" title="结果框">
<n-empty description="还没有结果哦">
<template #icon>
<n-icon :size="50">
<solar-cat-linear />
</n-icon>
</template>
</n-empty>
</n-card>
</main>
</template>
<style lang="scss" scoped>
.side-panel {
width: 100%;
height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 20px;
.search-input-box {
min-width: 270px;
}
.result-content-container {
width: 90%;
}
}
</style>

12
src/sidepanel/index.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<base target="_blank" />
<title>Sidepanel</title>
</head>
<body style="min-width: 100px">
<div id="app"></div>
<script type="module" src="./main.ts"></script>
</body>
</html>

7
src/sidepanel/main.ts Normal file
View File

@ -0,0 +1,7 @@
import App from './Sidepanel.vue';
import { setupApp } from '~/logic/common-setup';
import '../styles';
const app = createApp(App);
setupApp(app);
app.mount('#app');

1
src/styles/index.ts Normal file
View File

@ -0,0 +1 @@
import './main.css';

20
src/styles/main.css Normal file
View File

@ -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;
}

7
src/tests/demo.spec.ts Normal file
View File

@ -0,0 +1,7 @@
import { describe, expect, it } from 'vitest';
describe('demo', () => {
it('should work', () => {
expect(1 + 1).toBe(2);
});
});

22
tsconfig.json Normal file
View File

@ -0,0 +1,22 @@
{
"compilerOptions": {
"incremental": false,
"target": "es2016",
"jsx": "preserve",
"lib": ["DOM", "ESNext"],
"baseUrl": ".",
"module": "ESNext",
"moduleResolution": "node",
"paths": {
"~/*": ["src/*"]
},
"resolveJsonModule": true,
"types": ["vite/client"],
"strict": true,
"noUnusedLocals": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true
},
"exclude": ["dist", "node_modules"]
}

View File

@ -0,0 +1,36 @@
import { defineConfig } from 'vite';
import { sharedConfig } from './vite.config.mjs';
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,
},
},
},
});

37
vite.config.content.mts Normal file
View File

@ -0,0 +1,37 @@
import { defineConfig } from 'vite';
import { sharedConfig } from './vite.config.mjs';
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'],
cssFileName: 'index',
},
rollupOptions: {
output: {
entryFileNames: 'index.global.js',
extend: true,
},
},
},
});

114
vite.config.mts Normal file
View File

@ -0,0 +1,114 @@
/// <reference types="vitest" />
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 { NaiveUiResolver } from 'unplugin-vue-components/resolvers';
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']],
},
{
'naive-ui': [
'useDialog',
'useMessage',
'useNotification',
'useLoadingBar',
],
},
],
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({
prefix: '',
}),
NaiveUiResolver(),
],
}),
// https://github.com/antfu/unplugin-icons
Icons(),
// 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',
},
origin: `http://localhost:${port}`,
},
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'),
sidepanel: r('src/sidepanel/index.html'),
},
},
},
test: {
globals: true,
environment: 'jsdom',
},
}));