Merge pull request #4 from JosunLP/dev

Dev
This commit is contained in:
Jonas Pfalzgraf 2025-07-11 18:18:27 +02:00 committed by GitHub
commit 613a60c610
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 4198 additions and 623 deletions

24
.editorconfig Normal file
View file

@ -0,0 +1,24 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[*.json]
indent_size = 2
[*.ts]
indent_size = 2
[*.js]
indent_size = 2

39
.eslintrc.js Normal file
View file

@ -0,0 +1,39 @@
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
extends: ['eslint:recommended'],
parserOptions: {
ecmaVersion: 2020,
sourceType: 'module',
},
env: {
browser: true,
es6: true,
node: true,
},
globals: {
// Greasemonkey/Tampermonkey globals
GM_setValue: 'readonly',
GM_getValue: 'readonly',
GM_deleteValue: 'readonly',
GM_listValues: 'readonly',
GM_log: 'readonly',
GM_notification: 'readonly',
GM_openInTab: 'readonly',
GM_registerMenuCommand: 'readonly',
GM_unregisterMenuCommand: 'readonly',
GM_xmlhttpRequest: 'readonly',
GM_info: 'readonly',
unsafeWindow: 'readonly',
},
rules: {
'no-console': 'off',
'no-debugger': 'warn',
'prefer-const': 'error',
'no-var': 'error',
'no-unused-vars': 'off',
'no-undef': 'off',
},
ignorePatterns: ['dist/', 'node_modules/', 'tools/**/*.js'],
};

22
.gitignore vendored
View file

@ -643,5 +643,23 @@ FodyWeavers.xsd
# End of https://www.toptal.com/developers/gitignore/api/visualstudio,visualstudiocode,webstorm,intellij,sublimetext # End of https://www.toptal.com/developers/gitignore/api/visualstudio,visualstudiocode,webstorm,intellij,sublimetext
dist # Project specific
tools/userScriptHeader.js dist/
build/
tools/userScriptHeader.js
# Dependencies
node_modules/
# Environment files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# TypeScript
*.tsbuildinfo
# Logs
*.log

11
.prettierrc.json Normal file
View file

@ -0,0 +1,11 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"bracketSpacing": true,
"arrowParens": "avoid",
"endOfLine": "lf"
}

259
README.md
View file

@ -1,3 +1,258 @@
# UserScriptProjectTemplate # UserScript Project Template
A user script project template to create large and structured TypeScript projects for Tampermonkey or Greasemonkey. It is intended to form a scalable base and is primarily aimed at the Ingress community. [![GitHub license](https://img.shields.io/github/license/JosunLP/UserScriptProjectTemplate)](https://github.com/JosunLP/UserScriptProjectTemplate/blob/main/LICENSE)
[![GitHub issues](https://img.shields.io/github/issues/JosunLP/UserScriptProjectTemplate)](https://github.com/JosunLP/UserScriptProjectTemplate/issues)
[![GitHub stars](https://img.shields.io/github/stars/JosunLP/UserScriptProjectTemplate)](https://github.com/JosunLP/UserScriptProjectTemplate/stargazers)
## Description
A modern, production-ready template for building UserScripts using TypeScript and Vite. This template provides a solid foundation with best practices, type safety, and modern development tools for creating sophisticated Tampermonkey and Greasemonkey scripts.
## Features
• 🚀 **Modern Tech Stack:** TypeScript, Vite, ESLint, Prettier
• 🛡️ **Type Safety:** Strict TypeScript configuration with comprehensive UserScript API definitions
• 🔧 **Development Tools:** ESLint, Prettier, automated build pipeline
• 🎯 **Environment Support:** Separate development and production configurations
• 📦 **Modular Architecture:** Component system with reusable utilities
• 💾 **Storage Management:** Type-safe wrapper for GM_setValue/GM_getValue
• 🛠️ **Build System:** Optimized Vite configuration with automatic header generation
• 🎨 **DOM Utilities:** Helper functions for element manipulation and waiting
• 🔒 **Error Handling:** Comprehensive error boundary system
• ⚡ **Event System:** Type-safe event emitter for module communication
## Installation
### Quick Start
```bash
git clone https://github.com/JosunLP/UserScriptProjectTemplate.git
cd UserScriptProjectTemplate
npm install
```
### Development Setup
```bash
# Install dependencies
npm install
# Start development mode with auto-rebuild
npm run dev
# Type checking
npm run type-check
# Linting and formatting
npm run validate
```
## Usage
### Project Structure
```bash
src/
├── types/ # TypeScript type definitions
├── utils/ # Utility functions (Storage, DOM, Events)
├── core/ # Core application logic
├── modules/ # Feature modules
└── index.ts # Main application entry point
tools/
├── userScriptHeader.ts # UserScript header generator
└── userScriptHeader.js # Compiled header generator
assets/ # Icons and static resources
```
### Configuration
The main configuration is in `header.config.json`. This file controls UserScript metadata generation:
```json
{
"environments": {
"development": {
"includes": ["http://localhost:*/*", "https://localhost:*/*"],
"grants": ["GM_setValue", "GM_getValue", "GM_log", "GM_notification"]
},
"production": {
"includes": ["https://your-domain.com/*"],
"grants": ["GM_setValue", "GM_getValue"]
}
}
}
```
### Build Commands
```bash
# Development
npm run dev # Start development with watch mode
npm run dev:build # Single development build with header
npm run dev:header # Generate header for existing dev build
# Production
npm run build # Build for production
npm run build:prod # Explicit production build
# Quality Assurance
npm run validate # Type check + lint
npm run lint # ESLint with auto-fix
npm run format # Prettier formatting
# Utilities
npm run clean # Clean dist folder
npm run type-check # TypeScript type checking
```
### Development Workflow
1. **Configure your script** in `header.config.json`
2. **Start development:** `npm run dev`
3. **Write your code** in the `src/` directory
4. **Build for production:** `npm run build`
5. **Install the UserScript** from `dist/` folder in Tampermonkey/Greasemonkey
### Storage Management
The template includes a type-safe storage system:
```typescript
import { Storage } from '@/utils/storage';
// Save data
Storage.set('userData', { name: 'John', visits: 5 });
// Get data with default value
const userData = Storage.get('userData', { name: '', visits: 0 });
// Check if key exists
if (Storage.has('userData')) {
// Key exists
}
// Remove data
Storage.remove('userData');
```
### DOM Utilities
Helper functions for DOM manipulation:
```typescript
import { DOMUtils } from '@/utils/dom';
// Wait for element to appear
const element = await DOMUtils.waitForElement('.my-selector');
// Add custom styles
DOMUtils.addStyles(`
.my-class { color: red; }
`);
// Create element with attributes
const button = DOMUtils.createElement('button', {
textContent: 'Click me',
onclick: () => console.log('Clicked!'),
});
```
### Event System
Type-safe communication between modules:
```typescript
import { EventEmitter } from '@/utils/events';
interface MyEvents {
userAction: { userId: string };
dataLoaded: { count: number };
}
const emitter = new EventEmitter<MyEvents>();
// Listen for events
emitter.on('userAction', ({ userId }) => {
console.log(`User ${userId} performed an action`);
});
// Emit events
emitter.emit('userAction', { userId: '123' });
```
### Module System
Create reusable, event-driven modules:
```typescript
import { EventEmitter } from '@/utils/events';
interface ModuleEvents {
initialized: void;
actionPerformed: { action: string };
}
export class MyModule extends EventEmitter<ModuleEvents> {
async initialize() {
// Module initialization logic
this.emit('initialized', undefined);
}
}
```
## UserScript Compatibility
**Tampermonkey:** Full support with all GM\_\* APIs
**Greasemonkey:** Compatible with standard UserScript APIs
**Violentmonkey:** Full compatibility
**Safari:** Works with userscript managers
## Contributing
1. Fork the repository
2. Create a feature branch: `git checkout -b feature/amazing-feature`
3. Make your changes and ensure tests pass: `npm run validate`
4. Commit your changes: `git commit -m 'Add amazing feature'`
5. Push to the branch: `git push origin feature/amazing-feature`
6. Open a Pull Request
## Development Guidelines
• Follow TypeScript best practices
• Use meaningful variable and function names
• Add proper error handling
• Write self-documenting code
• Follow the established project structure
• Run `npm run validate` before committing
## License
This project is licensed under the [MIT License](https://opensource.org/licenses/MIT).
## Author
**_Jonas Pfalzgraf_**
• Email: [info@josunlp.de](mailto:info@josunlp.de)
• GitHub: [@JosunLP](https://github.com/JosunLP)
## Changelog
### v0.0.1 (Current)
• ✨ Modern TypeScript setup with strict type checking
• 🛡️ Comprehensive UserScript API definitions
• 🎨 Modular architecture with utilities and components
• 🔧 ESLint and Prettier configuration
• 📦 Optimized Vite build system
• 🚀 Environment-based configuration
• 💾 Type-safe storage management
• 🎯 Event-driven module system
• ⚡ DOM manipulation utilities
• 🛠️ Automated header generation
---
**_Built with ❤️ for the UserScript community_**

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Before After
Before After

View file

@ -3,25 +3,48 @@
"downloadUrl": "", "downloadUrl": "",
"supportUrl": "", "supportUrl": "",
"iconUrl": "./assets/icon.png", "iconUrl": "./assets/icon.png",
"includes": [ "environments": {
"https://*.josunlp.de/*", "development": {
"https://josunlp.de/*" "includes": [
], "http://localhost:*/*",
"excludes": [], "https://localhost:*/*",
"https://*.josunlp.de/*",
"https://josunlp.de/*"
],
"excludes": [],
"grants": [
"GM_setValue",
"GM_getValue",
"GM_deleteValue",
"GM_listValues",
"GM_log",
"GM_notification",
"GM_registerMenuCommand",
"GM_unregisterMenuCommand"
]
},
"production": {
"includes": [
"https://*.josunlp.de/*",
"https://josunlp.de/*"
],
"excludes": [],
"grants": [
"GM_setValue",
"GM_getValue",
"GM_deleteValue",
"GM_listValues",
"GM_registerMenuCommand"
]
}
},
"requires": [], "requires": [],
"resources": [], "resources": [],
"connecters": [], "connecters": [],
"matches": [ "matches": [],
"https://*.josunlp.de/*",
"https://josunlp.de/*"
],
"matchAllFrames": false, "matchAllFrames": false,
"runAt": "document-start", "runAt": "document-start",
"grants": [
"GM_setValue",
"GM_getValue"
],
"antifeatures": [], "antifeatures": [],
"noframes": false, "noframes": false,
"unwrap": false "unwrap": false
} }

3457
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,24 +1,38 @@
{ {
"name": "userscript-project-template", "name": "userscript-project-template",
"version": "0.0.1", "version": "1.0.0",
"description": "A user script project template to create large and structured TypeScript projects for Tampermonkey or Greasemonkey. It is intended to form a scalable base and is primarily aimed at the Ingress community.", "description": "A modern, production-ready template for building UserScripts using TypeScript and Vite. This template provides a solid foundation with best practices, type safety, and modern development tools for Tampermonkey and Greasemonkey scripts.",
"main": "index.ts", "main": "index.ts",
"module": "node", "module": "node",
"scripts": { "scripts": {
"dev": "vite build --watch --mode development",
"dev:header": "npm run build-userScriptHeader",
"dev:build": "vite build --mode development && npm run build-userScriptHeader",
"build": "vite build && npm run build-userScriptHeader", "build": "vite build && npm run build-userScriptHeader",
"build:prod": "vite build --mode production && npm run build-userScriptHeader",
"build-tooling": "tsc ./tools/userScriptHeader.ts --resolveJsonModule --esModuleInterop", "build-tooling": "tsc ./tools/userScriptHeader.ts --resolveJsonModule --esModuleInterop",
"build-userScriptHeader": "npm run build-tooling && node ./tools/userScriptHeader.js" "build-userScriptHeader": "npm run build-tooling && node ./tools/userScriptHeader.js",
"validate": "npm run type-check && npm run lint",
"lint": "eslint src/ --ext .ts,.tsx",
"lint:fix": "eslint src/ --ext .ts,.tsx --fix",
"format": "prettier --write \"src/**/*.{ts,tsx,json}\"",
"type-check": "tsc --noEmit",
"clean": "rimraf dist"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/JosunLP/UserScriptProjectTemplate.git" "url": "git+https://github.com/JosunLP/UserScriptProjectTemplate.git"
}, },
"keywords": [ "keywords": [
"User", "userscript",
"Script", "tampermonkey",
"TypeScript", "greasemonkey",
"Webpack", "typescript",
"Ingress" "template",
"vite",
"browser-automation",
"web-enhancement",
"browser-scripting"
], ],
"author": "Jonas Pfalzgraf <info@josunlp.de>", "author": "Jonas Pfalzgraf <info@josunlp.de>",
"license": "MIT", "license": "MIT",
@ -27,11 +41,18 @@
}, },
"homepage": "https://github.com/JosunLP/UserScriptProjectTemplate#readme", "homepage": "https://github.com/JosunLP/UserScriptProjectTemplate#readme",
"devDependencies": { "devDependencies": {
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"eslint": "^8.57.1",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.5.1",
"prettier": "^3.6.2",
"rimraf": "^5.0.10",
"ts-loader": "^9.3.1", "ts-loader": "^9.3.1",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"typescript": "^4.7.4", "typescript": "^5.4.0",
"undici-types": "^6.18.2", "undici-types": "^6.18.2",
"vite": "^5.2.12", "vite": "^7.0.4",
"vite-tsconfig-paths": "^4.3.2", "vite-tsconfig-paths": "^4.3.2",
"webpack": "^5.74.0", "webpack": "^5.74.0",
"webpack-cli": "^4.10.0" "webpack-cli": "^4.10.0"

View file

@ -1,15 +1,147 @@
/** import { ExampleModule } from '@/modules/example';
* App import { DOMUtils } from '@/utils/dom';
*/ import { EventEmitter } from '@/utils/events';
class App { import { Storage } from '@/utils/storage';
constructor() { /**
this.main(); * Application events interface
} */
interface AppEvents {
private main() { ready: void;
console.log('Hello World!'); error: Error;
} moduleLoaded: { name: string };
} }
new App(); /**
* Main Application Class
*
* This is the entry point for your UserScript.
* Extend this class to build your specific functionality.
*/
class App extends EventEmitter<AppEvents> {
private isInitialized = false;
private modules: Map<string, any> = new Map();
constructor() {
super();
this.initialize();
}
/**
* Initialize the application
*/
private async initialize(): Promise<void> {
try {
console.log('🚀 UserScript starting...');
// Wait for DOM to be ready
if (document.readyState === 'loading') {
await new Promise(resolve => {
document.addEventListener('DOMContentLoaded', resolve, { once: true });
});
}
await this.main();
this.isInitialized = true;
this.emit('ready', undefined);
console.log('✅ UserScript initialized successfully');
} catch (error) {
console.error('❌ UserScript initialization failed:', error);
this.emit('error', error as Error);
}
}
/**
* Main application logic
* Override this method in your implementation
*/
protected async main(): Promise<void> {
console.log('👋 Hello from UserScript Template!');
// Example: Add some basic functionality
this.addExampleFeatures();
// Initialize modules
await this.initializeModules();
}
/**
* Initialize application modules
*/
private async initializeModules(): Promise<void> {
try {
// Initialize example module
const exampleModule = new ExampleModule();
await exampleModule.initialize();
// Register the module
this.registerModule('example', exampleModule);
// Listen to module events
exampleModule.on('actionPerformed', ({ action, timestamp }) => {
console.log(
`📡 Module action received: ${action} at ${new Date(timestamp).toLocaleString()}`
);
});
} catch (error) {
console.error('❌ Failed to initialize modules:', error);
}
}
/**
* Example features to demonstrate the template
*/
private addExampleFeatures(): void {
// Example: Add custom styles
DOMUtils.addStyles(
`
.userscript-highlight {
background-color: yellow !important;
border: 2px solid red !important;
}
`,
'userscript-styles'
);
// Example: Storage usage
const visitCount = (Storage.get<number>('visitCount', 0) || 0) + 1;
Storage.set('visitCount', visitCount);
console.log(`📊 This is visit #${visitCount}`);
// Example: Add menu command
if (typeof GM_registerMenuCommand !== 'undefined') {
GM_registerMenuCommand('Show Visit Count', () => {
const count = Storage.get<number>('visitCount', 0);
alert(`You have visited this page ${count} times!`);
});
}
}
/**
* Register a module
*/
protected registerModule(name: string, module: any): void {
this.modules.set(name, module);
this.emit('moduleLoaded', { name });
console.log(`📦 Module "${name}" loaded`);
}
/**
* Get a registered module
*/
protected getModule<T = any>(name: string): T | undefined {
return this.modules.get(name);
}
/**
* Check if app is initialized
*/
public get ready(): boolean {
return this.isInitialized;
}
}
// Start the application
new App();

251
src/modules/example.ts Normal file
View file

@ -0,0 +1,251 @@
import { DOMUtils } from '@/utils/dom';
import { EventEmitter } from '@/utils/events';
import { Storage } from '@/utils/storage';
/**
* Example module events
*/
interface ExampleModuleEvents {
initialized: void;
actionPerformed: { action: string; timestamp: number };
}
/**
* Example module to demonstrate the template structure
*
* This module shows how to:
* - Extend the EventEmitter for module communication
* - Use storage for persistent data
* - Manipulate DOM elements
* - Register menu commands
*/
export class ExampleModule extends EventEmitter<ExampleModuleEvents> {
private isInitialized = false;
private actionCount = 0;
constructor() {
super();
}
/**
* Initialize the module
*/
async initialize(): Promise<void> {
try {
console.log('📦 Initializing ExampleModule...');
// Load persistent data
this.actionCount = Storage.get<number>('exampleModule.actionCount', 0) || 0;
// Wait for required DOM elements (example)
await this.waitForPageElements();
// Add custom styles
this.addModuleStyles();
// Register menu commands
this.registerMenuCommands();
// Set up event listeners
this.setupEventListeners();
this.isInitialized = true;
this.emit('initialized', undefined);
console.log('✅ ExampleModule initialized successfully');
} catch (error) {
console.error('❌ ExampleModule initialization failed:', error);
throw error;
}
}
/**
* Wait for required page elements
*/
private async waitForPageElements(): Promise<void> {
// Example: Wait for body to be available
await DOMUtils.waitForElement('body', 5000);
console.log('📄 Page body is ready');
}
/**
* Add module-specific styles
*/
private addModuleStyles(): void {
DOMUtils.addStyles(
`
.example-module-button {
position: fixed;
top: 20px;
right: 20px;
z-index: 9999;
padding: 10px 15px;
background: #007cba;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
transition: background 0.2s;
}
.example-module-button:hover {
background: #005a87;
}
.example-module-notification {
position: fixed;
top: 70px;
right: 20px;
z-index: 9998;
padding: 10px;
background: #28a745;
color: white;
border-radius: 5px;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
opacity: 0;
transition: opacity 0.3s;
}
.example-module-notification.show {
opacity: 1;
}
`,
'example-module-styles'
);
}
/**
* Register UserScript menu commands
*/
private registerMenuCommands(): void {
if (typeof GM_registerMenuCommand !== 'undefined') {
GM_registerMenuCommand('🎯 Perform Example Action', () => {
this.performAction('menu-command');
});
GM_registerMenuCommand('📊 Show Module Stats', () => {
this.showStats();
});
GM_registerMenuCommand('🧹 Reset Module Data', () => {
this.resetData();
});
}
}
/**
* Set up event listeners
*/
private setupEventListeners(): void {
// Add a floating button for demonstration
const button = DOMUtils.createElement('button', {
className: 'example-module-button',
textContent: '🎯 Example Action',
onclick: () => this.performAction('button-click'),
});
document.body.appendChild(button);
// Listen to keyboard shortcuts
document.addEventListener('keydown', event => {
// Ctrl+Shift+E to perform action
if (event.ctrlKey && event.shiftKey && event.key === 'E') {
event.preventDefault();
this.performAction('keyboard-shortcut');
}
});
}
/**
* Perform an example action
*/
public performAction(trigger: string): void {
this.actionCount++;
const timestamp = Date.now();
// Store the updated count
Storage.set('exampleModule.actionCount', this.actionCount);
// Emit event
this.emit('actionPerformed', { action: trigger, timestamp });
// Show notification
this.showNotification(`Action performed via ${trigger}! (Total: ${this.actionCount})`);
console.log(`🎯 Example action performed via ${trigger} (count: ${this.actionCount})`);
}
/**
* Show a temporary notification
*/
private showNotification(message: string, duration = 3000): void {
const notification = DOMUtils.createElement('div', {
className: 'example-module-notification',
textContent: message,
});
document.body.appendChild(notification);
// Trigger animation
setTimeout(() => notification.classList.add('show'), 10);
// Remove after duration
setTimeout(() => {
notification.classList.remove('show');
setTimeout(() => DOMUtils.removeElement(notification), 300);
}, duration);
}
/**
* Show module statistics
*/
private showStats(): void {
const stats = {
initialized: this.isInitialized,
actionCount: this.actionCount,
lastAction: Storage.get<number>('exampleModule.lastActionTime', 0),
};
const message = [
'Example Module Statistics:',
`• Initialized: ${stats.initialized ? 'Yes' : 'No'}`,
`• Actions performed: ${stats.actionCount}`,
`• Last action: ${stats.lastAction ? new Date(stats.lastAction).toLocaleString() : 'Never'}`,
].join('\n');
if (typeof GM_notification !== 'undefined') {
GM_notification(message, 'Module Stats');
} else {
alert(message);
}
}
/**
* Reset module data
*/
private resetData(): void {
if (confirm('Are you sure you want to reset all module data?')) {
Storage.remove('exampleModule.actionCount');
Storage.remove('exampleModule.lastActionTime');
this.actionCount = 0;
console.log('🧹 Example module data reset');
this.showNotification('Module data reset!');
}
}
/**
* Get module status
*/
public get initialized(): boolean {
return this.isInitialized;
}
/**
* Get action count
*/
public get totalActions(): number {
return this.actionCount;
}
}

99
src/types/userscript.d.ts vendored Normal file
View file

@ -0,0 +1,99 @@
/**
* TypeScript definitions for UserScript environment
*/
declare global {
/**
* Greasemonkey/Tampermonkey API
*/
function GM_setValue(key: string, value: any): void;
function GM_getValue(key: string, defaultValue?: any): any;
function GM_deleteValue(key: string): void;
function GM_listValues(): string[];
function GM_log(message: string): void;
function GM_notification(
text: string,
title?: string,
image?: string,
onclick?: () => void
): void;
function GM_openInTab(
url: string,
options?: { active?: boolean; insert?: boolean; setParent?: boolean }
): void;
function GM_registerMenuCommand(name: string, fn: () => void, accessKey?: string): number;
function GM_unregisterMenuCommand(menuCmdId: number): void;
function GM_xmlhttpRequest(details: GM_XHRDetails): GM_XHRResponse;
interface GM_XHRDetails {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS';
url: string;
headers?: Record<string, string>;
data?: string | FormData | Blob;
binary?: boolean;
timeout?: number;
context?: any;
responseType?: 'text' | 'json' | 'blob' | 'arraybuffer' | 'document';
overrideMimeType?: string;
anonymous?: boolean;
fetch?: boolean;
username?: string;
password?: string;
onload?: (response: GM_XHRResponse) => void;
onerror?: (response: GM_XHRResponse) => void;
onabort?: () => void;
ontimeout?: () => void;
onprogress?: (response: GM_XHRProgressResponse) => void;
onreadystatechange?: (response: GM_XHRResponse) => void;
onloadstart?: (response: GM_XHRResponse) => void;
onloadend?: (response: GM_XHRResponse) => void;
}
interface GM_XHRResponse {
readyState: number;
responseHeaders: string;
responseText: string;
response: any;
status: number;
statusText: string;
finalUrl: string;
}
interface GM_XHRProgressResponse extends GM_XHRResponse {
lengthComputable: boolean;
loaded: number;
total: number;
}
/**
* UserScript Info
*/
interface GM_Info {
script: {
description: string;
excludes: string[];
includes: string[];
matches: string[];
name: string;
namespace: string;
resources: Record<string, string>;
runAt: string;
version: string;
};
scriptMetaStr: string;
scriptWillUpdate: boolean;
version: string;
}
const GM_info: GM_Info;
/**
* UnsafeWindow for accessing page's global scope
*/
const unsafeWindow: Window & typeof globalThis;
}
export {};

126
src/utils/dom.ts Normal file
View file

@ -0,0 +1,126 @@
/**
* DOM utility functions for UserScript development
*/
export class DOMUtils {
/**
* Wait for element to appear in DOM
*/
static waitForElement(
selector: string,
timeout = 10000,
root: Document | Element = document
): Promise<Element> {
return new Promise((resolve, reject) => {
const element = root.querySelector(selector);
if (element) {
resolve(element);
return;
}
const observer = new MutationObserver(mutations => {
for (const mutation of mutations) {
const nodes = Array.from(mutation.addedNodes);
for (const node of nodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
const element =
(node as Element).querySelector?.(selector) || (node as Element).matches?.(selector)
? (node as Element)
: null;
if (element) {
observer.disconnect();
resolve(element);
return;
}
}
}
}
});
observer.observe(root, {
childList: true,
subtree: true,
});
setTimeout(() => {
observer.disconnect();
reject(new Error(`Element "${selector}" not found within ${timeout}ms`));
}, timeout);
});
}
/**
* Wait for multiple elements
*/
static waitForElements(
selectors: string[],
timeout = 10000,
root: Document | Element = document
): Promise<Element[]> {
return Promise.all(selectors.map(selector => this.waitForElement(selector, timeout, root)));
}
/**
* Create element with attributes and content
*/
static createElement<K extends keyof HTMLElementTagNameMap>(
tagName: K,
attributes: Partial<HTMLElementTagNameMap[K]> = {},
content?: string | Node
): HTMLElementTagNameMap[K] {
const element = document.createElement(tagName);
Object.assign(element, attributes);
if (content !== undefined) {
if (typeof content === 'string') {
element.textContent = content;
} else {
element.appendChild(content);
}
}
return element;
}
/**
* Add CSS styles to page
*/
static addStyles(css: string, id?: string): HTMLStyleElement {
const style = document.createElement('style');
style.textContent = css;
if (id) {
style.id = id;
}
(document.head || document.documentElement).appendChild(style);
return style;
}
/**
* Remove element safely
*/
static removeElement(element: Element | string): boolean {
const el = typeof element === 'string' ? document.querySelector(element) : element;
if (el && el.parentNode) {
el.parentNode.removeChild(el);
return true;
}
return false;
}
/**
* Check if element is visible
*/
static isVisible(element: Element): boolean {
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
return (
rect.width > 0 &&
rect.height > 0 &&
style.visibility !== 'hidden' &&
style.display !== 'none' &&
style.opacity !== '0'
);
}
}

78
src/utils/events.ts Normal file
View file

@ -0,0 +1,78 @@
/**
* Type-safe event emitter for UserScript modules
*/
export interface EventMap {
[event: string]: any;
}
export class EventEmitter<T extends EventMap = EventMap> {
private listeners: { [K in keyof T]?: Array<(data: T[K]) => void> } = {};
/**
* Add event listener
*/
on<K extends keyof T>(event: K, listener: (data: T[K]) => void): void {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event]!.push(listener);
}
/**
* Add one-time event listener
*/
once<K extends keyof T>(event: K, listener: (data: T[K]) => void): void {
const onceListener = (data: T[K]) => {
listener(data);
this.off(event, onceListener);
};
this.on(event, onceListener);
}
/**
* Remove event listener
*/
off<K extends keyof T>(event: K, listener: (data: T[K]) => void): void {
const eventListeners = this.listeners[event];
if (eventListeners) {
const index = eventListeners.indexOf(listener);
if (index > -1) {
eventListeners.splice(index, 1);
}
}
}
/**
* Emit event
*/
emit<K extends keyof T>(event: K, data: T[K]): void {
const eventListeners = this.listeners[event];
if (eventListeners) {
eventListeners.forEach(listener => {
try {
listener(data);
} catch (error) {
console.error(`Error in event listener for "${String(event)}":`, error);
}
});
}
}
/**
* Remove all listeners for an event
*/
removeAllListeners<K extends keyof T>(event?: K): void {
if (event) {
delete this.listeners[event];
} else {
this.listeners = {};
}
}
/**
* Get listener count for an event
*/
listenerCount<K extends keyof T>(event: K): number {
return this.listeners[event]?.length || 0;
}
}

61
src/utils/storage.ts Normal file
View file

@ -0,0 +1,61 @@
/**
* Storage utility for UserScript persistent data
*/
export class Storage {
/**
* Set a value in persistent storage
*/
static set<T>(key: string, value: T): void {
try {
const serialized = JSON.stringify(value);
GM_setValue(key, serialized);
} catch (error) {
console.error(`Failed to store value for key "${key}":`, error);
}
}
/**
* Get a value from persistent storage
*/
static get<T>(key: string, defaultValue?: T): T | undefined {
try {
const stored = GM_getValue(key);
if (stored === undefined) {
return defaultValue;
}
return JSON.parse(stored) as T;
} catch (error) {
console.error(`Failed to retrieve value for key "${key}":`, error);
return defaultValue;
}
}
/**
* Remove a value from persistent storage
*/
static remove(key: string): void {
GM_deleteValue(key);
}
/**
* Check if a key exists in storage
*/
static has(key: string): boolean {
return GM_getValue(key) !== undefined;
}
/**
* Get all keys from storage
*/
static getAllKeys(): string[] {
return GM_listValues();
}
/**
* Clear all storage (use with caution!)
*/
static clear(): void {
const keys = GM_listValues();
keys.forEach(key => GM_deleteValue(key));
}
}

View file

@ -1,15 +1,34 @@
import * as fs from "fs"; import * as fs from 'fs';
import pkg from "../package.json"; import config from '../header.config.json';
import config from "../header.config.json"; import pkg from '../package.json';
const targetFile = "./dist/" + pkg.name + ".user.js"; // Check which file exists to determine the environment
const prodFile = `./dist/${pkg.name}.user.js`;
const devFile = `./dist/${pkg.name}.dev.user.js`;
let targetFile: string;
let environment: 'production' | 'development';
if (fs.existsSync(prodFile)) {
targetFile = prodFile;
environment = 'production';
} else if (fs.existsSync(devFile)) {
targetFile = devFile;
environment = 'development';
} else {
console.error('❌ No UserScript file found in dist/');
console.error(`Expected either: ${prodFile} or ${devFile}`);
process.exit(1);
}
console.log(`🔧 Building UserScript header for ${environment} (${targetFile})`);
/** /**
* Appends header * Appends header
* @param header * @param header
*/ */
function appendHeader(header: string) { function appendHeader(header: string) {
fs.readFile(targetFile, "utf8", (err: NodeJS.ErrnoException | null, data: string) => { fs.readFile(targetFile, 'utf8', (err: NodeJS.ErrnoException | null, data: string) => {
if (err) { if (err) {
throw err; throw err;
} }
@ -28,7 +47,7 @@ function appendHeader(header: string) {
*/ */
async function buildBase64UrlFromFile(filePath: string): Promise<string> { async function buildBase64UrlFromFile(filePath: string): Promise<string> {
const file = await fs.promises.readFile(filePath); const file = await fs.promises.readFile(filePath);
return "data:image/png;base64," + file.toString("base64"); return 'data:image/png;base64,' + file.toString('base64');
} }
/** /**
@ -38,7 +57,7 @@ async function buildBase64UrlFromFile(filePath: string): Promise<string> {
* @returns multiple entries * @returns multiple entries
*/ */
function generateMultipleEntries(type: string, array: string[]): string { function generateMultipleEntries(type: string, array: string[]): string {
let result: string = ""; let result: string = '';
if (array) { if (array) {
array.forEach((item: string) => { array.forEach((item: string) => {
result += `// ${type} ${item}\n`; result += `// ${type} ${item}\n`;
@ -53,28 +72,43 @@ function generateMultipleEntries(type: string, array: string[]): string {
* @returns string without empty lines * @returns string without empty lines
*/ */
function removeEmptyLinesFromString(string: string): string { function removeEmptyLinesFromString(string: string): string {
return string.replace(/\n\s*\n/g, "\n"); return string.replace(/\n\s*\n/g, '\n');
} }
/** /**
* Generates user script header * Generates user script header
*/ */
async function generateUserScriptHeader() { async function generateUserScriptHeader() {
const excludes = generateMultipleEntries("@exclude", config.excludes); // Determine environment from NODE_ENV or default to production
const requires = generateMultipleEntries("@require", config.requires); const environment = process.env.NODE_ENV === 'development' ? 'development' : 'production';
const resources = generateMultipleEntries("@resource", config.resources);
const connecters = generateMultipleEntries("@connect", config.connecters); // Get environment-specific config or fallback to empty arrays
const grants = generateMultipleEntries("@grant", config.grants); const envConfig = config.environments?.[environment] || {
const matches = generateMultipleEntries("@match", config.matches); includes: [],
const includes = generateMultipleEntries("@match", config.includes); excludes: [],
const antifeatures = generateMultipleEntries("@antifeature", config.antifeatures); grants: [],
};
const excludes = generateMultipleEntries('@exclude', envConfig.excludes);
const requires = generateMultipleEntries('@require', config.requires);
const resources = generateMultipleEntries('@resource', config.resources);
const connecters = generateMultipleEntries('@connect', config.connecters);
const grants = generateMultipleEntries('@grant', envConfig.grants);
// Use environment-specific includes as matches, fallback to root matches if available
const allMatches = envConfig.includes.length > 0 ? envConfig.includes : config.matches;
const matches = generateMultipleEntries('@match', allMatches);
// No includes needed as we use matches
const includes = '';
const antifeatures = generateMultipleEntries('@antifeature', config.antifeatures);
const base64url = await buildBase64UrlFromFile(config.iconUrl); const base64url = await buildBase64UrlFromFile(config.iconUrl);
let noframes = ""; let noframes = '';
let matchAllFrames = ""; let matchAllFrames = '';
let updateUrl = ""; let updateUrl = '';
let downloadUrl = ""; let downloadUrl = '';
let supportUrl = ""; let supportUrl = '';
if (config.noframes) { if (config.noframes) {
noframes = `// @noframes\n`; noframes = `// @noframes\n`;
@ -82,13 +116,13 @@ async function generateUserScriptHeader() {
if (config.matchAllFrames) { if (config.matchAllFrames) {
matchAllFrames = `// @matchAllFrames\n`; matchAllFrames = `// @matchAllFrames\n`;
} }
if (config.updateUrl !== "") { if (config.updateUrl !== '') {
updateUrl = `// @updateURL ${config.updateUrl}\n`; updateUrl = `// @updateURL ${config.updateUrl}\n`;
} }
if (config.downloadUrl !== "") { if (config.downloadUrl !== '') {
downloadUrl = `// @downloadURL ${config.downloadUrl}\n`; downloadUrl = `// @downloadURL ${config.downloadUrl}\n`;
} }
if (config.supportUrl !== "") { if (config.supportUrl !== '') {
supportUrl = `// @supportURL ${config.supportUrl}\n`; supportUrl = `// @supportURL ${config.supportUrl}\n`;
} }
@ -117,7 +151,7 @@ ${matchAllFrames}
`; `;
header = removeEmptyLinesFromString(header); header = removeEmptyLinesFromString(header);
header += "\n"; header += '\n';
appendHeader(header); appendHeader(header);
} }

View file

@ -1,16 +1,32 @@
{ {
"compilerOptions": { "compilerOptions": {
"strict": true,
"noImplicitAny": true, "noImplicitAny": true,
"noImplicitReturns": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"exactOptionalPropertyTypes": true,
"noFallthroughCasesInSwitch": true,
"module": "ESNext", "module": "ESNext",
"target": "ES6", "target": "ES2020",
"lib": ["ES2020", "DOM"],
"allowJs": false, "allowJs": false,
"moduleResolution": "node", "moduleResolution": "node",
"sourceMap": true, "sourceMap": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"esModuleInterop": true, "esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": "./src",
"paths": { "paths": {
"undici-types": ["./node_modules/undici/types/index.d.ts"] "@/*": ["*"],
"@/types/*": ["types/*"],
"@/utils/*": ["utils/*"],
"@/core/*": ["core/*"],
"undici-types": ["../node_modules/undici/types/index.d.ts"]
} }
} },
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tools/**/*.js"]
} }

View file

@ -1,21 +1,33 @@
import { defineConfig } from "vite";
import { resolve } from "path"; import { resolve } from "path";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths"; import tsconfigPaths from "vite-tsconfig-paths";
import pkgjsn from "./package.json"; import pkgjsn from "./package.json";
export default defineConfig({ export default defineConfig(({ mode }) => {
build: { const isDev = mode === 'development';
rollupOptions: {
input: resolve(__dirname, "src/index.ts"), return {
output: { build: {
entryFileNames: `${pkgjsn.name}.user.js`, rollupOptions: {
dir: resolve(__dirname, "dist"), input: resolve(__dirname, "src/index.ts"),
output: {
entryFileNames: `${pkgjsn.name}${isDev ? '.dev' : ''}.user.js`,
dir: resolve(__dirname, "dist"),
},
},
sourcemap: isDev ? "inline" : false,
minify: isDev ? false : 'terser',
},
plugins: [tsconfigPaths()],
resolve: {
extensions: [".tsx", ".ts", ".js"],
alias: {
'@': resolve(__dirname, 'src'),
}, },
}, },
sourcemap: "inline", // Equivalent to 'inline-source-map' define: {
}, __DEV__: isDev,
plugins: [tsconfigPaths()], __VERSION__: JSON.stringify(pkgjsn.version),
resolve: { },
extensions: [".tsx", ".ts", ".js"], };
},
}); });