├── .browserslistrc ├── .editorconfig ├── .eslintignore ├── .eslintrc.cjs ├── .github └── workflows │ └── publish.yml ├── .gitignore ├── .husky ├── .gitignore └── pre-push ├── .npmrc ├── LICENSE ├── README.md ├── babel.config.cjs ├── package.json ├── pnpm-lock.yaml ├── rollup.config.mjs ├── src ├── index.ts └── util.ts ├── test ├── index.test.ts └── util.test.ts └── tsconfig.json /.browserslistrc: -------------------------------------------------------------------------------- 1 | node >= 16 2 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | quote_type = single 12 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /* 2 | !/src 3 | !/test 4 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: [ 4 | require.resolve('@gera2ld/plaid/eslint'), 5 | ], 6 | settings: { 7 | 'import/resolver': { 8 | 'babel-module': {}, 9 | }, 10 | }, 11 | rules: { 12 | 'import/no-unresolved': 'off', 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to npmjs 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | 14 | - uses: pnpm/action-setup@v4 15 | name: Install pnpm 16 | with: 17 | version: 9 18 | run_install: false 19 | 20 | - name: Install Node.js 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: 22 24 | cache: 'pnpm' 25 | registry-url: 'https://registry.npmjs.org' 26 | 27 | - run: pnpm i && pnpm publish --no-git-checks 28 | env: 29 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .pnpm-store 2 | node_modules 3 | *.log 4 | /.idea 5 | /dist 6 | /.nyc_output 7 | /coverage 8 | /types 9 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npm run lint 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist = true 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Gerald <gera2ld@live.com> 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rollup-plugin-userscript 2 | 3 | ![NPM](https://img.shields.io/npm/v/rollup-plugin-userscript.svg) 4 | ![License](https://img.shields.io/npm/l/rollup-plugin-userscript.svg) 5 | ![Downloads](https://img.shields.io/npm/dt/rollup-plugin-userscript.svg) 6 | 7 | Automatically parse metadata and set `@grant`s. 8 | 9 | With this plugin, `@grant`s for [`GM_*` functions](https://violentmonkey.github.io/api/metadata-block/) will be added at compile time. 10 | 11 | ## Usage 12 | 13 | Add the plugin to rollup.config.js: 14 | 15 | ```js 16 | import userscript from 'rollup-plugin-userscript'; 17 | 18 | const plugins = [ 19 | // ... 20 | userscript(meta => meta.replace('process.env.AUTHOR', pkg.author)), 21 | ]; 22 | ``` 23 | 24 | Import the metadata file with a suffix `?userscript-metadata` in your script: 25 | 26 | ```js 27 | import './meta.js?userscript-metadata'; 28 | ``` 29 | -------------------------------------------------------------------------------- /babel.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: require.resolve('@gera2ld/plaid/config/babelrc-base'), 3 | presets: ['@babel/preset-typescript'], 4 | }; 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rollup-plugin-userscript", 3 | "version": "0.3.7", 4 | "description": "Rollup plugin for userscript", 5 | "author": "Gerald ", 6 | "license": "MIT", 7 | "scripts": { 8 | "ci": "run-s lint test", 9 | "lint": "eslint src", 10 | "dev": "rollup -wc", 11 | "clean": "del-cli dist", 12 | "build:js": "rollup -c", 13 | "build:types": "tsc", 14 | "build": "run-s ci clean build:js build:types", 15 | "test": "cross-env NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" jest", 16 | "prepare": "husky install", 17 | "prepublishOnly": "run-s build" 18 | }, 19 | "type": "module", 20 | "module": "./dist/index.js", 21 | "types": "./dist/index.d.ts", 22 | "exports": { 23 | ".": { 24 | "import": "./dist/index.js", 25 | "types": "./dist/index.d.ts" 26 | } 27 | }, 28 | "files": [ 29 | "dist" 30 | ], 31 | "keywords": [ 32 | "rollup", 33 | "userscript" 34 | ], 35 | "publishConfig": { 36 | "access": "public", 37 | "registry": "https://registry.npmjs.org/" 38 | }, 39 | "dependencies": { 40 | "@babel/runtime": "^7.23.7", 41 | "@rollup/pluginutils": "^5.1.0", 42 | "estree-walker": "^3.0.3", 43 | "is-reference": "^3.0.2", 44 | "magic-string": "^0.30.7" 45 | }, 46 | "devDependencies": { 47 | "@gera2ld/plaid": "~2.7.0", 48 | "@gera2ld/plaid-rollup": "~2.7.0", 49 | "@gera2ld/plaid-test": "^2.6.0", 50 | "@types/node": "^20.10.6", 51 | "cross-env": "^7.0.3", 52 | "del-cli": "^5.1.0", 53 | "husky": "^8.0.3" 54 | }, 55 | "repository": "git@github.com:violentmonkey/rollup-plugin-userscript.git", 56 | "engines": { 57 | "node": ">=18" 58 | }, 59 | "jest": { 60 | "extensionsToTreatAsEsm": [ 61 | ".ts" 62 | ] 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineExternal, definePlugins } from '@gera2ld/plaid-rollup'; 2 | import { builtinModules } from 'module'; 3 | import { defineConfig } from 'rollup'; 4 | import pkg from './package.json' with { type: 'json' }; 5 | 6 | const BANNER = `/*! ${pkg.name} v${pkg.version} | ${pkg.license} License */`; 7 | 8 | const external = defineExternal([ 9 | ...builtinModules, 10 | ...Object.keys(pkg.dependencies), 11 | ]); 12 | export default defineConfig({ 13 | input: 'src/index.ts', 14 | plugins: definePlugins({ 15 | esm: true, 16 | }), 17 | external, 18 | output: { 19 | format: 'esm', 20 | file: 'dist/index.js', 21 | indent: false, 22 | // If set to false, circular dependencies and live bindings for external imports won't work 23 | externalLiveBindings: false, 24 | banner: BANNER, 25 | }, 26 | }); 27 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { readFile } from 'fs/promises'; 2 | import MagicString from 'magic-string'; 3 | import type { Plugin } from 'rollup'; 4 | import { collectGmApi, getMetadata } from './util'; 5 | 6 | const suffix = '?userscript-metadata'; 7 | 8 | export default (transform?: (metadata: string) => string): Plugin => { 9 | const metadataMap = new Map(); 10 | const grantMap = new Map(); 11 | return { 12 | name: 'userscript-metadata', 13 | async resolveId(source, importer, options) { 14 | if (source.endsWith(suffix)) { 15 | let { id } = await this.resolve(source, importer, options); 16 | if (id.endsWith(suffix)) id = id.slice(0, -suffix.length); 17 | metadataMap.set(importer, id); 18 | return source; 19 | } 20 | }, 21 | load(id) { 22 | if (id.endsWith(suffix)) { 23 | return ''; 24 | } 25 | }, 26 | transform(code, id) { 27 | const ast = this.parse(code); 28 | const grantSetPerFile = collectGmApi(ast); 29 | grantMap.set(id, grantSetPerFile); 30 | }, 31 | /** 32 | * Use `renderChunk` instead of `banner` to preserve the metadata after minimization. 33 | * Note that this plugin must be put after `@rollup/plugin-terser`. 34 | */ 35 | async renderChunk(code, chunk) { 36 | const metadataFile = 37 | chunk.isEntry && 38 | [chunk.facadeModuleId, ...Object.keys(chunk.modules)] 39 | .map((id) => metadataMap.get(id)) 40 | .find(Boolean); 41 | if (!metadataFile) return; 42 | let metadata = await readFile(metadataFile, 'utf8'); 43 | const grantSet = new Set(); 44 | for (const id of this.getModuleIds()) { 45 | const grantSetPerFile = grantMap.get(id); 46 | if (grantSetPerFile) { 47 | for (const item of grantSetPerFile) { 48 | grantSet.add(item); 49 | } 50 | } 51 | } 52 | metadata = getMetadata(metadata, grantSet); 53 | if (transform) metadata = transform(metadata); 54 | const s = new MagicString(code); 55 | s.prepend(`${metadata}\n\n`); 56 | return { 57 | code: s.toString(), 58 | map: s.generateMap({ hires: 'boundary' }).toString(), 59 | }; 60 | }, 61 | }; 62 | }; 63 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import { AttachedScope, attachScopes } from '@rollup/pluginutils'; 2 | import { Node, walk } from 'estree-walker'; 3 | import isReference from 'is-reference'; 4 | import type { AstNode } from 'rollup'; 5 | 6 | const gmAPIs = [ 7 | 'GM_info', 8 | 'GM_getValue', 9 | 'GM_getValues', 10 | 'GM_setValue', 11 | 'GM_setValues', 12 | 'GM_deleteValue', 13 | 'GM_deleteValues', 14 | 'GM_listValues', 15 | 'GM_addValueChangeListener', 16 | 'GM_removeValueChangeListener', 17 | 'GM_getResourceText', 18 | 'GM_getResourceURL', 19 | 'GM_addElement', 20 | 'GM_addStyle', 21 | 'GM_openInTab', 22 | 'GM_registerMenuCommand', 23 | 'GM_unregisterMenuCommand', 24 | 'GM_notification', 25 | 'GM_setClipboard', 26 | 'GM_xmlhttpRequest', 27 | 'GM_download', 28 | ]; 29 | const META_START = '// ==UserScript=='; 30 | const META_END = '// ==/UserScript=='; 31 | 32 | export function collectGmApi(ast: AstNode) { 33 | let scope = attachScopes(ast, 'scope'); 34 | const grantSetPerFile = new Set(); 35 | walk(ast as Node, { 36 | enter(node: Node & { scope: AttachedScope }, parent) { 37 | if (node.scope) scope = node.scope; 38 | if ( 39 | node.type === 'Identifier' && 40 | isReference(node, parent) && 41 | !scope.contains(node.name) 42 | ) { 43 | if (gmAPIs.includes(node.name)) { 44 | grantSetPerFile.add(node.name); 45 | } 46 | } 47 | }, 48 | leave(node: Node & { scope: AttachedScope }) { 49 | if (node.scope) scope = scope.parent; 50 | }, 51 | }); 52 | return grantSetPerFile; 53 | } 54 | 55 | export function getMetadata( 56 | metaFileContent: string, 57 | additionalGrantList: Set, 58 | ) { 59 | const lines = metaFileContent.split('\n').map((line) => line.trim()); 60 | const start = lines.indexOf(META_START); 61 | const end = lines.indexOf(META_END); 62 | if (start < 0 || end < 0) { 63 | throw new Error( 64 | 'Invalid metadata block. For more details see https://violentmonkey.github.io/api/metadata-block/', 65 | ); 66 | } 67 | const grantSet = new Set(); 68 | const entries = lines 69 | .slice(start + 1, end) 70 | .map((line) => { 71 | if (!line.startsWith('// ')) return; 72 | line = line.slice(3).trim(); 73 | const matches = line.match(/^(\S+)(\s.*)?$/); 74 | if (!matches) return; 75 | const key = matches[1]; 76 | const value = (matches[2] || '').trim(); 77 | if (key === '@grant') { 78 | grantSet.add(value); 79 | return; 80 | } 81 | return [key, value]; 82 | }) 83 | .filter(Boolean); 84 | for (const item of additionalGrantList) { 85 | grantSet.add(item); 86 | } 87 | const grantList = Array.from(grantSet); 88 | grantList.sort(); 89 | for (const item of grantList) { 90 | entries.push(['@grant', item]); 91 | } 92 | const maxKeyWidth = Math.max(...entries.map(([key]) => key.length)); 93 | return [ 94 | META_START, 95 | ...entries.map(([key, value]) => `// ${key.padEnd(maxKeyWidth)} ${value}`), 96 | META_END, 97 | ].join('\n'); 98 | } 99 | -------------------------------------------------------------------------------- /test/index.test.ts: -------------------------------------------------------------------------------- 1 | import userscript from '../src/index'; 2 | 3 | describe('rollup-plugin-userscript', () => { 4 | it('should return "userscript-metadata" as the plugin name', () => { 5 | expect(userscript().name).toBe('userscript-metadata'); 6 | }); 7 | }); -------------------------------------------------------------------------------- /test/util.test.ts: -------------------------------------------------------------------------------- 1 | import type { AstNode } from 'rollup'; 2 | import type { EmptyStatement } from 'estree'; 3 | 4 | import { 5 | collectGmApi, 6 | getMetadata 7 | } from '../src/util'; 8 | 9 | describe('collectGmApi', () => { 10 | const EMPTY_STATEMENT: EmptyStatement = { 11 | type: 'EmptyStatement' 12 | }; 13 | 14 | it('should return an empty set on an empty input', () => { 15 | expect(collectGmApi(EMPTY_STATEMENT as AstNode).size).toBe(0); 16 | }); 17 | }); 18 | 19 | describe('getMetadata', () => { 20 | it('should throw error on an empty input', () => { 21 | expect(() => getMetadata('', new Set())).toThrow(Error); 22 | }); 23 | }); -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "moduleResolution": "node", 6 | "declaration": true, 7 | "emitDeclarationOnly": true, 8 | "outDir": "dist", 9 | "skipLibCheck": true 10 | }, 11 | "include": ["src/**/*"] 12 | } 13 | --------------------------------------------------------------------------------