├── .eslintignore ├── .eslintrc.yml ├── .github ├── FUNDING.yml └── workflows │ ├── ci.yaml │ └── pr.yaml ├── .gitignore ├── LICENSE ├── README.md ├── RELEASE.md ├── __tests__ ├── api.test.ts ├── config-example.ts ├── operations.test.ts └── sass.test.ts ├── bootstrap ├── .gitignore ├── metadata.ts ├── package-lock.json ├── package.json ├── src │ ├── config.ts │ ├── main.ts │ ├── operations.ts │ ├── preferences.ts │ ├── scss.d.ts │ ├── site.ts │ ├── stylesheets.ts │ └── userscript.ts ├── tsconfig.json ├── tsconfig.webpack.json └── webpack.config.ts ├── jest.config.mjs ├── package-lock.json ├── package.json ├── src ├── build-time │ ├── index.ts │ ├── internal │ │ ├── configuration.ts │ │ ├── messages.ts │ │ ├── mode.ts │ │ ├── parsing.ts │ │ ├── sass.ts │ │ ├── utilities.ts │ │ ├── validation.ts │ │ ├── webpack-plugin.ts │ │ └── webpack.ts │ └── node-sass-utils.d.ts └── run-time │ ├── environment.ts │ ├── errors.ts │ ├── index.ts │ ├── log.ts │ ├── operations.ts │ ├── preferences.ts │ ├── stylesheets.ts │ └── userscripter.ts └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | # Compiled files: 2 | /build-time/ 3 | /run-time/ 4 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | extends: 2 | - eslint:recommended 3 | - plugin:@typescript-eslint/recommended 4 | plugins: 5 | - import 6 | root: true 7 | rules: 8 | "@typescript-eslint/ban-tslint-comment": error 9 | "@typescript-eslint/explicit-module-boundary-types": off 10 | "@typescript-eslint/no-explicit-any": off 11 | "@typescript-eslint/no-namespace": [ error ] 12 | "@typescript-eslint/no-unused-vars": off 13 | "arrow-spacing": [ error, { before: true, after: true } ] 14 | "block-spacing": [ error, always ] 15 | "comma-style": [ error, last ] 16 | "comma-dangle": [ 17 | error, 18 | { 19 | arrays: "always-multiline", 20 | objects: "always-multiline", 21 | imports: "always-multiline", 22 | exports: "always-multiline", 23 | functions: "always-multiline", 24 | }, 25 | ] 26 | "import/order": [ 27 | error, 28 | { 29 | alphabetize: { order: asc }, 30 | groups: [ builtin, external, parent, sibling ], 31 | newlines-between: always, 32 | }, 33 | ] 34 | "key-spacing": [ error, { beforeColon: false, afterColon: true, mode: minimum } ] 35 | "keyword-spacing": [ error, { before: true, after: true } ] 36 | "no-console": error 37 | "no-constant-condition": error 38 | "no-duplicate-imports": [ error, { includeExports: true } ] 39 | "no-unused-vars": off # Should be turned off when using @typescript-eslint. 40 | "no-unused-expressions": error 41 | "no-var": error 42 | "prefer-const": error 43 | "quotes": [ error, "double", { allowTemplateLiterals: true, avoidEscape: true } ] 44 | "semi-spacing": [ error, { before: false, after: true } ] 45 | "semi": [ error, always ] 46 | "spaced-comment": [ error, always ] 47 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: alling 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - v1 8 | pull_request: 9 | branches: 10 | - master 11 | - v1 12 | 13 | jobs: 14 | nightly: 15 | name: Check package (Node ${{ matrix.node-version }}) 16 | runs-on: ubuntu-22.04 17 | strategy: 18 | matrix: 19 | node-version: [22.14.0] 20 | steps: 21 | - uses: actions/checkout@v2 22 | with: 23 | fetch-depth: 1 24 | - name: Use Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v1 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | - name: Install dependencies 29 | run: | 30 | npm ci 31 | - name: Lint 32 | run: | 33 | npm run lint 34 | - name: Test 35 | run: | 36 | npm test 37 | - name: Build 38 | run: | 39 | npm run build 40 | env: 41 | CI: true 42 | bootstrap: 43 | name: Check bootstrapped userscript (Node ${{ matrix.node-version }}) 44 | runs-on: ubuntu-22.04 45 | strategy: 46 | matrix: 47 | node-version: [22.14.0] 48 | steps: 49 | - uses: actions/checkout@v2 50 | with: 51 | fetch-depth: 1 52 | - name: Use Node.js ${{ matrix.node-version }} 53 | uses: actions/setup-node@v1 54 | with: 55 | node-version: ${{ matrix.node-version }} 56 | - name: Bootstrap userscript 57 | # This step is NOT intended to check how any changes to the library/package might affect the bootstrapped userscript. 58 | # It only checks changes to the bootstrapped userscript itself. 59 | working-directory: ${{ runner.temp }} 60 | run: | 61 | mkdir bootstrapped-userscript 62 | cd $_ 63 | npx tiged@2.12.7 https://github.com/${{ github.repository }}/bootstrap#${{ github.sha }} 64 | npm ci 65 | npm run build 66 | userscripts: 67 | name: "Dependent: ${{ matrix.repository.humanReadableName }} (Node ${{ matrix.node-version }})" 68 | runs-on: ubuntu-22.04 69 | strategy: 70 | matrix: 71 | repository: 72 | - humanReadableName: Better SweClockers 73 | ownerSlashName: SimonAlling/better-sweclockers 74 | refInRepo: 64ddf8a1541356261c4eebeeedfdb408b41b2d42 75 | pathInRepo: "" 76 | - humanReadableName: Example Userscript 77 | ownerSlashName: SimonAlling/example-userscript 78 | refInRepo: b42b8717c421cf5dca5c6e4bc2ee8396125bd6bb 79 | pathInRepo: "" 80 | - humanReadableName: Bootstrapped Userscript 81 | ownerSlashName: SimonAlling/userscripter 82 | refInRepo: ${{ github.sha }} # The purpose here, unlike in the `bootstrap` job, _is_ to check how any changes to the library/package might affect the bootstrapped userscript. 83 | pathInRepo: bootstrap/ 84 | node-version: [22.14.0] 85 | fail-fast: false 86 | steps: 87 | - uses: actions/checkout@v2 88 | with: 89 | fetch-depth: 1 90 | - name: Use Node.js ${{ matrix.node-version }} 91 | uses: actions/setup-node@v1 92 | with: 93 | node-version: ${{ matrix.node-version }} 94 | - name: Install dependencies 95 | run: | 96 | npm ci 97 | - name: Build 98 | run: | 99 | npm run build 100 | - name: Pack 101 | id: pack 102 | run: | 103 | echo "tarball=$(pwd)/$(npm pack)" >> $GITHUB_OUTPUT 104 | - name: Clone userscript 105 | uses: actions/checkout@v2 106 | with: 107 | repository: ${{ matrix.repository.ownerSlashName }} 108 | ref: ${{ matrix.repository.refInRepo }} 109 | path: dependent-userscripts/${{ matrix.repository.ownerSlashName }} # Must be relative to github.workspace, apparently. 110 | fetch-depth: 1 111 | - name: Move userscript 112 | run: | 113 | mkdir -p "${TARGET_DIR}" 114 | mv --no-target-directory "${{ github.workspace }}/dependent-userscripts/${{ matrix.repository.ownerSlashName }}" "${TARGET_DIR}" 115 | env: 116 | TARGET_DIR: ${{ runner.temp }}/${{ matrix.repository.ownerSlashName }} 117 | - name: Install userscript dependencies 118 | working-directory: ${{ runner.temp }}/${{ matrix.repository.ownerSlashName }}/${{ matrix.repository.pathInRepo }} 119 | run: | 120 | npm ci 121 | - name: Install Userscripter 122 | working-directory: ${{ runner.temp }}/${{ matrix.repository.ownerSlashName }}/${{ matrix.repository.pathInRepo }} 123 | run: | 124 | npm install "${{ steps.pack.outputs.tarball }}" 125 | - name: Build userscript 126 | working-directory: ${{ runner.temp }}/${{ matrix.repository.ownerSlashName }}/${{ matrix.repository.pathInRepo }} 127 | run: | 128 | npm run build 129 | -------------------------------------------------------------------------------- /.github/workflows/pr.yaml: -------------------------------------------------------------------------------- 1 | name: PR 2 | on: 3 | pull_request: # https://securitylab.github.com/research/github-actions-preventing-pwn-requests/ 4 | types: 5 | - opened 6 | - edited 7 | - synchronize 8 | 9 | jobs: 10 | main: 11 | name: Conventional Commits 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Check PR title 15 | uses: amannn/action-semantic-pull-request@v3.4.0 16 | env: 17 | GITHUB_TOKEN: ${{ github.token }} 18 | with: 19 | validateSingleCommit: true 20 | subjectPattern: ^(v\d+\.\d+\.\d+|(?![a-z]).+)$ 21 | subjectPatternError: | 22 | The subject "{subject}" found in the pull request title didn't match the required pattern. Please ensure that the subject doesn't start with a lowercase character, or that it is a SemVer version (e.g. "v1.0.0"). 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | run-time/** 3 | build-time/** 4 | *.tgz 5 | *.html 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017–2019 Simon Alling 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 | # Userscripter 2 | 3 | Create [userscripts](https://wiki.greasespot.net/User_script) in a breeze! 4 | 5 | * Safe, declarative DOM operations and stylesheets 6 | * Straightforward preference management 7 | * TypeScript constants in SASS code 8 | * Build as native browser extension (optional) 9 | * Metadata validation 10 | * Static typing 11 | 12 | 13 | ## Getting started 14 | 15 | ### Prerequisites 16 | 17 | * **[Node.js](https://nodejs.org) with npm is required.** 18 | * **If you are using Windows**, you may need to install and use [Git Bash](https://git-scm.com/downloads), [Linux Subsystem](https://msdn.microsoft.com/en-us/commandline/wsl/install-win10) or similar to be able to build. 19 | 20 | ### Create a new userscript 21 | 22 | ```bash 23 | cd path/to/my-new-userscript 24 | npx tiged@2.12.7 https://github.com/SimonAlling/userscripter/bootstrap#master 25 | ``` 26 | 27 | If everything went well, an `src` directory should have been created, along with some other files like `package.json` and `webpack.config.ts`. 28 | You should now be able to build the userscript: 29 | 30 | ```bash 31 | npm ci 32 | npm run build 33 | ``` 34 | 35 | The compiled userscript should be saved as `dist/bootstrapped-userscript.user.js`. 36 | 37 | ### Install the userscript 38 | 39 | Userscripts are usually installed through a browser extension, for example **Violentmonkey** ([Firefox][violentmonkey-firefox], [Chrome][violentmonkey-chrome]). 40 | Please refer to the documentation for the one you use: 41 | 42 | * [_Install a local script - Violentmonkey_](https://violentmonkey.github.io/posts/how-to-edit-scripts-with-your-favorite-editor/#install-a-local-script) 43 | * [_Greasemonkey Manual:Installing Scripts_](https://wiki.greasespot.net/Greasemonkey_Manual:Installing_Scripts) 44 | * [_How to install new scripts to Tampermonkey_](http://tampermonkey.net/faq.php#Q102) 45 | 46 | ### Check that the userscript works 47 | 48 | Go to [`http://example.com`](http://example.com). 49 | If you haven't modified anything, you should see a green background and `[Bootstrapped Userscript] Bootstrapped Userscript 0.1.0` in the developer console. 50 | 51 | 52 | ## How to write a userscript 53 | 54 | A userscript typically consists primarily of **DOM operations** and **stylesheets**. 55 | It can also have user-facing **preferences**. Check out these repositories for examples: 56 | 57 | * [Example Userscript][example-userscript] is a basic userscript featuring [operations][example-userscript-operations], [stylesheets][example-userscript-stylesheets], [preferences][example-userscript-preferences] and a [preferences menu][example-userscript-preferences-menu]. 58 | * [Better SweClockers][better-sweclockers] is a large, full-fledged, real-world userscript. 59 | 60 | 61 | ## Build options 62 | 63 | The `buildConfig` property of the object passed to `createWebpackConfig` controls how the userscript is built (see e.g. [`webpack.config.ts` in the example repo][example-userscript-webpack-config]). 64 | 65 | You can override certain options on the command line using environment variables: 66 | 67 | ```bash 68 | USERSCRIPTER_MODE=production USERSCRIPTER_VERBOSE=true npm run build 69 | ``` 70 | 71 | (With `USERSCRIPTER_VERBOSE=true`, all available environment variables are listed.) 72 | 73 | You can also customize the object _returned_ from `createWebpackConfig` in `webpack.config.ts`: 74 | 75 | ```typescript 76 | import { createWebpackConfig } from 'userscripter/build-time'; 77 | 78 | const webpackConfig = createWebpackConfig({ 79 | // … 80 | }); 81 | 82 | export default { 83 | ...webpackConfig, 84 | resolve: { 85 | ...webpackConfig.resolve, 86 | alias: { 87 | ...webpackConfig.resolve?.alias, 88 | "react": "preact/compat", // Adding an alias here. 89 | }, 90 | }, 91 | // Other properties (e.g. 'stats') could be added/overridden here. 92 | }; 93 | ``` 94 | 95 | Such customizations will take precedence over environment variables. 96 | 97 | ## Native browser extension 98 | 99 | To create a [native browser extension][webextension] from your userscript, include a [manifest][manifest-json] in the object passed to `createWebpackConfig` ([example][example-userscript-webpack-config]). 100 | `manifest.json` will then be created alongside the compiled `.user.js` file. 101 | You can then use [`web-ext`][web-ext] to build the native extension: 102 | 103 | ```bash 104 | npm install -g web-ext 105 | cd dist 106 | web-ext build 107 | ``` 108 | 109 | 110 | [violentmonkey-firefox]: https://addons.mozilla.org/en-US/firefox/addon/violentmonkey/ 111 | [violentmonkey-chrome]: https://chrome.google.com/webstore/detail/violentmonkey/jinjaccalgkegednnccohejagnlnfdag 112 | [better-sweclockers]: https://github.com/SimonAlling/better-sweclockers 113 | [example-userscript]: https://github.com/SimonAlling/example-userscript 114 | [example-userscript-operations]: https://github.com/SimonAlling/example-userscript/blob/master/src/operations.ts 115 | [example-userscript-stylesheets]: https://github.com/SimonAlling/example-userscript/blob/master/src/stylesheets.ts 116 | [example-userscript-preferences]: https://github.com/SimonAlling/example-userscript/blob/master/src/preferences.ts 117 | [example-userscript-preferences-menu]: https://github.com/SimonAlling/example-userscript/blob/master/src/preferences-menu.tsx 118 | [example-userscript-webpack-config]: https://github.com/SimonAlling/example-userscript/blob/master/webpack.config.ts 119 | [webextension]: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions 120 | [manifest-json]: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json 121 | [web-ext]: https://www.npmjs.com/package/web-ext 122 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Release workflow 2 | 3 | ## Release a new version of the npm package 4 | 5 | 1. Get up to date: 6 | 7 | ```bash 8 | cd $(git rev-parse --show-toplevel) && git checkout master && git pull && git clean -xdf && git restore . 9 | ``` 10 | 11 | 1. Pick a SemVer release level based on the changes since the last release (typically `major`, `minor` or `patch`): 12 | 13 | ```bash 14 | RELEASE_LEVEL=some_release_level 15 | ``` 16 | 17 | 1. Make, commit and push the source code changes: 18 | 19 | ```bash 20 | THE_VERSION=$(npm version --no-git-tag-version "${RELEASE_LEVEL:?}") 21 | git switch --create "${THE_VERSION:?}" 22 | git add package*.json 23 | git commit -m "chore: ${THE_VERSION:?}" 24 | git push -u origin "${THE_VERSION:?}" 25 | ``` 26 | 27 | 1. Create a PR based on the newly pushed branch. 28 | 29 | > ⚠️ **Warning** 30 | > 31 | > The PR description should include a brief summary of the user-facing changes included in the release. 32 | > 33 | > For major releases, a migration guide should also be included. 34 | 35 | 1. Review and merge the PR. 36 | 37 | 1. Get up to date: 38 | 39 | ```bash 40 | git checkout master && git pull 41 | ``` 42 | 43 | 1. Make sure you're on the newly created commit, whose subject should be something like `chore: v1.2.3 (#42)`. Otherwise, find it and move to it with `git checkout`. 44 | 45 | 1. Tag the newly created commit and push the tag: 46 | 47 | ```bash 48 | git tag "${THE_VERSION:?}" && git push origin "refs/tags/${THE_VERSION:?}" 49 | ``` 50 | 51 | 1. Build from a clean slate and publish the package: 52 | 53 | ```bash 54 | git clean -xdf && npm publish # `npm publish` should automatically build first (see `prepublishOnly` script). 55 | ``` 56 | 57 | ## Update the bootstrapped userscript 58 | 59 | These steps aren't strictly necessary, but it typically makes sense for the bootstrapped userscript to use the latest version of the package. 60 | 61 | > [!IMPORTANT] 62 | > This can only be done if a new version of the package was successfully published in the previous section. 63 | 64 | 1. Install the newly published version in the bootstrapped userscript: 65 | 66 | ```bash 67 | cd "$(git rev-parse --show-toplevel)/bootstrap" 68 | git clean -xdf . 69 | THE_BOOTSTRAP_BRANCH="bootstrap-${THE_VERSION:?}" 70 | git switch --create "${THE_BOOTSTRAP_BRANCH:?}" 71 | npm ci 72 | npm install -E "userscripter@${THE_VERSION:?}" 73 | git add package*.json 74 | npm run build 75 | ``` 76 | 77 | 1. Modify the bootstrapped userscript if necessary, then stage the changes: 78 | 79 | ```bash 80 | git add -p 81 | ``` 82 | 83 | 1. Commit and push: 84 | 85 | ```bash 86 | git commit -m "chore: Use ${THE_VERSION:?} in bootstrapped userscript" 87 | git push -u origin "${THE_BOOTSTRAP_BRANCH:?}" 88 | ``` 89 | 90 | 1. Create a PR based on the newly pushed branch. 91 | 92 | 1. Review and merge the PR. 93 | -------------------------------------------------------------------------------- /__tests__/api.test.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import * as path from "path"; 3 | 4 | import * as index from "../src/run-time"; 5 | import * as environment from "../src/run-time/environment"; 6 | import * as errors from "../src/run-time/errors"; 7 | import * as operations from "../src/run-time/operations"; 8 | import * as stylesheets from "../src/run-time/stylesheets"; 9 | 10 | it("exposes the intended API", () => { 11 | const a: environment.Condition = environment.ALWAYS; 12 | void a; 13 | expect(environment.DOMCONTENTLOADED).toBeDefined(); 14 | expect(environment.LOAD).toBeDefined(); 15 | 16 | expect(errors.explanation).toBeDefined(); 17 | expect(errors.failureDescriber).toBeDefined(); 18 | 19 | expect(operations.Reason.DEPENDENCIES).toBeDefined(); 20 | expect(operations.Reason.INTERNAL).toBeDefined(); 21 | expect(operations.operation).toBeDefined(); 22 | expect(operations.run).toBeDefined(); 23 | 24 | expect(stylesheets).toBeDefined(); 25 | expect(stylesheets.insert).toBeDefined(); 26 | expect(stylesheets.enable).toBeDefined(); 27 | expect(stylesheets.disable).toBeDefined(); 28 | }); 29 | 30 | it("exposes everything in run-time in index.ts", async () => { 31 | const filenames = await fs.promises.readdir(path.resolve(__dirname, "..", "src", "run-time")); 32 | expect(filenames).toEqual([ 33 | "environment.ts", 34 | "errors.ts", 35 | "index.ts", 36 | "log.ts", 37 | "operations.ts", 38 | "preferences.ts", 39 | "stylesheets.ts", 40 | "userscripter.ts", 41 | ]); 42 | const modulesThatAreExported = Object.keys(index); 43 | const modulesThatShouldBeExported = filenames.map(n => n.replace(/\.ts$/, "")).filter(n => n !== "index"); 44 | expect(modulesThatAreExported).toEqual(modulesThatShouldBeExported); 45 | }); 46 | -------------------------------------------------------------------------------- /__tests__/config-example.ts: -------------------------------------------------------------------------------- 1 | export const EXAMPLE_HEIGHT = "200px"; 2 | export const EXAMPLE_FONT_SIZE = "2em"; 3 | -------------------------------------------------------------------------------- /__tests__/operations.test.ts: -------------------------------------------------------------------------------- 1 | import { operations } from "../src/run-time"; 2 | import { 3 | ALWAYS, 4 | DOMCONTENTLOADED, 5 | } from "../src/run-time/environment"; 6 | import { failureDescriber } from "../src/run-time/errors"; 7 | import { 8 | Operation, 9 | OperationAndFailure, 10 | operation, 11 | } from "../src/run-time/operations"; 12 | 13 | const mockConsole = { 14 | log: (message: string) => void message, 15 | error: (message: string) => void message, 16 | }; 17 | 18 | const consoleLog = jest.spyOn(mockConsole, "log"); 19 | const consoleError = jest.spyOn(mockConsole, "error"); 20 | beforeEach(() => { 21 | consoleLog.mockReset(); 22 | consoleError.mockReset(); 23 | }); 24 | 25 | const CONTEXT = { 26 | siteName: "Example Site", 27 | extensionName: "Example Extension", 28 | location: document.location, 29 | }; 30 | 31 | const PLAN = { 32 | interval: 100, 33 | tryUntil: DOMCONTENTLOADED, 34 | extraTries: 3, 35 | handleFailures, 36 | }; 37 | 38 | function handleFailures(failures: ReadonlyArray>) { 39 | failures.forEach(f => mockConsole.error(failureDescriber(CONTEXT)(f))); 40 | } 41 | 42 | const HTML_EXAMPLE = ` 43 | Original Title 44 |

Original Heading

45 | 46 | `; 47 | 48 | const BLABLABLA = "blablabla"; 49 | const BLA = "bla"; 50 | 51 | const HTML_WITH_BLABLABLA = ` 52 | Hello 53 | 54 | `; 55 | 56 | const HTML_WITHOUT_BLABLABLA = ` 57 | Hello 58 | 59 | `; 60 | 61 | function removeFooter(e: { footer: HTMLElement }) { 62 | e.footer.remove(); 63 | } 64 | 65 | function logBlablablaProperty(e: { body: HTMLElement }): string | void { 66 | const value = e.body.dataset[BLABLABLA]; 67 | if (value !== undefined) { 68 | mockConsole.log(value); 69 | } else { 70 | return `Property '${BLABLABLA}' not found.`; 71 | } 72 | } 73 | 74 | const OPERATIONS: ReadonlyArray> = [ 75 | operation({ 76 | description: "do nothing", 77 | condition: ALWAYS, 78 | action: () => { /* Do nothing. */ }, 79 | }), 80 | operation({ 81 | description: "change title", 82 | condition: ALWAYS, 83 | dependencies: {}, 84 | action: () => document.title = "Test", 85 | }), 86 | operation({ 87 | description: "change heading", 88 | condition: ALWAYS, 89 | dependencies: { heading: "h1" }, 90 | action: e => e.heading.textContent = "Test", 91 | }), 92 | operation({ 93 | description: "remove footer", 94 | condition: ALWAYS, 95 | dependencies: { footer: "footer" }, 96 | action: removeFooter, 97 | }), 98 | ]; 99 | 100 | const OPERATIONS_BLABLABLA = [ 101 | operation({ 102 | description: `log ${BLABLABLA}`, 103 | condition: ALWAYS, 104 | dependencies: { body: "body" }, 105 | action: logBlablablaProperty, 106 | }), 107 | ]; 108 | 109 | it("can run operations", () => { 110 | document.documentElement.innerHTML = HTML_EXAMPLE; 111 | operations.run({ 112 | ...PLAN, 113 | operations: OPERATIONS, 114 | }); 115 | expect(document.title).toMatchInlineSnapshot(`"Test"`); 116 | }); 117 | 118 | it("can log " + BLABLABLA, () => { 119 | document.documentElement.innerHTML = HTML_WITH_BLABLABLA; 120 | operations.run({ 121 | ...PLAN, 122 | operations: OPERATIONS_BLABLABLA, 123 | }); 124 | expect(consoleLog).toHaveBeenCalledWith(BLA); 125 | expect(consoleError).not.toHaveBeenCalled(); 126 | }); 127 | 128 | it("can handle an internal failure", () => { 129 | document.documentElement.innerHTML = HTML_WITHOUT_BLABLABLA; 130 | operations.run({ 131 | ...PLAN, 132 | operations: OPERATIONS_BLABLABLA, 133 | }); 134 | expect(consoleLog).not.toHaveBeenCalled(); 135 | expect(consoleError).toHaveBeenCalled(); 136 | }); 137 | -------------------------------------------------------------------------------- /__tests__/sass.test.ts: -------------------------------------------------------------------------------- 1 | import sass from "sass"; 2 | 3 | import { getGlobalFrom, withDartSassEncodedParameters } from "../src/build-time/internal/sass"; 4 | import { DEFAULT_BUILD_CONFIG } from "../src/build-time/internal/webpack"; 5 | 6 | import * as CONFIG from "./config-example"; 7 | 8 | const actualDefaultVariableGetter = DEFAULT_BUILD_CONFIG({ rootDir: "", id: "", now: new Date() }).sassVariableGetter; 9 | const expectedDefaultVariableGetter = "getGlobal"; 10 | 11 | describe("SASS variable getter", () => { 12 | it("can be called using the expected default name", () => { 13 | const getGlobal = getGlobalFrom({ CONFIG }); 14 | const scssTemplate = `div { min-height: #{${expectedDefaultVariableGetter}("CONFIG.EXAMPLE_HEIGHT")} }`; 15 | const encodedFunctionName = withDartSassEncodedParameters(actualDefaultVariableGetter, getGlobal); 16 | expect(encodedFunctionName).toBe(`${actualDefaultVariableGetter}($x0)`); 17 | const sassRenderConfig: sass.Options = { 18 | data: scssTemplate, 19 | outputStyle: "compressed", 20 | functions: { 21 | [encodedFunctionName]: getGlobal, 22 | }, 23 | }; 24 | const result = sass.renderSync(sassRenderConfig); 25 | expect(result.css.toString()).toBe(`div{min-height:200px}`); 26 | }); 27 | 28 | it("can be called using the actual default name", () => { 29 | const getGlobal = getGlobalFrom({ CONFIG }); 30 | const scssTemplate = `div { min-height: #{${actualDefaultVariableGetter}("CONFIG.EXAMPLE_HEIGHT")} }`; 31 | const encodedFunctionName = withDartSassEncodedParameters(actualDefaultVariableGetter, getGlobal); 32 | const sassRenderConfig: sass.Options = { 33 | data: scssTemplate, 34 | outputStyle: "compressed", 35 | functions: { 36 | [encodedFunctionName]: getGlobal, 37 | }, 38 | }; 39 | const result = sass.renderSync(sassRenderConfig); 40 | expect(result.css.toString()).toBe(`div{min-height:200px}`); 41 | }); 42 | 43 | it("can be called using a custom name", () => { 44 | const getGlobal = getGlobalFrom({ CONFIG }); 45 | const variableGetter = "getFoo"; 46 | const scssTemplate = `div { min-height: #{${variableGetter}("CONFIG.EXAMPLE_HEIGHT")} }`; 47 | const encodedFunctionName = withDartSassEncodedParameters(variableGetter, getGlobal); 48 | const sassRenderConfig: sass.Options = { 49 | data: scssTemplate, 50 | outputStyle: "compressed", 51 | functions: { 52 | [encodedFunctionName]: getGlobal, 53 | }, 54 | }; 55 | const result = sass.renderSync(sassRenderConfig); 56 | expect(result.css.toString()).toBe(`div{min-height:200px}`); 57 | }); 58 | 59 | it("throws an error if the variable doesn't exist", () => { 60 | const getGlobal = getGlobalFrom({ CONFIG }); 61 | const variableGetter = expectedDefaultVariableGetter; 62 | const scssTemplate = `div { min-height: #{${variableGetter}("CONFIG.NON_EXISTENT")} }`; 63 | const sassRenderConfig: sass.Options = { 64 | data: scssTemplate, 65 | outputStyle: "compressed", 66 | functions: { 67 | [withDartSassEncodedParameters(variableGetter, getGlobal)]: getGlobal, 68 | }, 69 | }; 70 | expect(() => sass.renderSync(sassRenderConfig)).toThrowError(`Unknown global: 'CONFIG.NON_EXISTENT' (failed on 'NON_EXISTENT')`); 71 | }); 72 | 73 | it("throws an error if passed a non-string argument", () => { 74 | const getGlobal = getGlobalFrom({ CONFIG }); 75 | const variableGetter = expectedDefaultVariableGetter; 76 | const scssTemplate = `div { min-height: #{${variableGetter}(42)} }`; 77 | const sassRenderConfig: sass.Options = { 78 | data: scssTemplate, 79 | outputStyle: "compressed", 80 | functions: { 81 | [withDartSassEncodedParameters(variableGetter, getGlobal)]: getGlobal, 82 | }, 83 | }; 84 | expect(() => sass.renderSync(sassRenderConfig)).toThrowError(`Error: Expected a string as argument, but saw: 42`); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /bootstrap/.gitignore: -------------------------------------------------------------------------------- 1 | # This file is named gitignore rather than .gitignore because otherwise it would be renamed to .npmignore by npm when installing Userscripter via a tarball (e.g. from the npm registry). 2 | # https://github.com/SimonAlling/userscripter/issues/24 3 | 4 | node_modules 5 | *.log 6 | .babelrc 7 | .awcache 8 | dist/ 9 | .userscripter-temp 10 | -------------------------------------------------------------------------------- /bootstrap/metadata.ts: -------------------------------------------------------------------------------- 1 | import { Metadata } from "userscript-metadata"; 2 | import { 3 | BuildConfig, 4 | } from "userscripter/build-time"; 5 | 6 | import U from "./src/userscript"; 7 | 8 | export default function(_: BuildConfig): Metadata { 9 | return { 10 | name: U.name, 11 | version: U.version, 12 | description: U.description, 13 | author: U.author, 14 | match: [ 15 | `*://${U.hostname}/*`, 16 | `*://www.${U.hostname}/*`, 17 | ], 18 | namespace: U.namespace, 19 | run_at: U.runAt, 20 | }; 21 | } 22 | -------------------------------------------------------------------------------- /bootstrap/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bootstrapped-userscript", 3 | "private": true, 4 | "scripts": { 5 | "build-webpack-config": "tsc -p tsconfig.webpack.json", 6 | "build-userscript": "webpack --config .userscripter-temp/webpack.config.js", 7 | "build": "npm run clean && npm run build-webpack-config && npm run build-userscript && npm run clean", 8 | "clean": "rimraf .userscripter-temp" 9 | }, 10 | "dependencies": { 11 | "@types/app-root-path": "^1.2.4", 12 | "app-root-path": "^3.0.0", 13 | "rimraf": "^6.0.1", 14 | "ts-preferences": "^2.0.0", 15 | "typescript": "4.5.5", 16 | "userscript-metadata": "^1.0.0", 17 | "userscripter": "6.0.0", 18 | "webpack": "^4.41.5", 19 | "webpack-cli": "^3.3.10" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /bootstrap/src/config.ts: -------------------------------------------------------------------------------- 1 | // This file cannot contain Webpack-resolved imports (e.g. "~src/foo"). 2 | 3 | export const OPERATIONS_INTERVAL = 200; // ms 4 | export const OPERATIONS_EXTRA_TRIES = 3; 5 | -------------------------------------------------------------------------------- /bootstrap/src/main.ts: -------------------------------------------------------------------------------- 1 | import { environment, errors, log, userscripter } from "userscripter"; 2 | 3 | import * as CONFIG from "~src/config"; 4 | import OPERATIONS from "~src/operations"; 5 | import * as SITE from "~src/site"; 6 | import STYLESHEETS from "~src/stylesheets"; 7 | import U from "~src/userscript"; 8 | 9 | const describeFailure = errors.failureDescriber({ 10 | siteName: SITE.NAME, 11 | extensionName: U.name, 12 | location: document.location, 13 | }); 14 | 15 | userscripter.run({ 16 | id: U.id, 17 | name: U.name, 18 | initialAction: () => log.log(`${U.name} ${U.version}`), 19 | stylesheets: STYLESHEETS, 20 | operationsPlan: { 21 | operations: OPERATIONS, 22 | interval: CONFIG.OPERATIONS_INTERVAL, 23 | tryUntil: environment.DOMCONTENTLOADED, 24 | extraTries: CONFIG.OPERATIONS_EXTRA_TRIES, 25 | handleFailures: failures => failures.forEach(f => log.error(describeFailure(f))), 26 | }, 27 | }); 28 | -------------------------------------------------------------------------------- /bootstrap/src/operations.ts: -------------------------------------------------------------------------------- 1 | import { Operation, operation } from "userscripter/run-time/operations"; 2 | 3 | const OPERATIONS: ReadonlyArray> = [ 4 | ]; 5 | 6 | export default OPERATIONS; 7 | -------------------------------------------------------------------------------- /bootstrap/src/preferences.ts: -------------------------------------------------------------------------------- 1 | import { 2 | PreferenceManager, 3 | } from "ts-preferences"; 4 | import { loggingResponseHandler } from "userscripter/run-time/preferences"; 5 | 6 | import U from "~src/userscript"; 7 | 8 | export const P = { 9 | } as const; 10 | 11 | export const Preferences = new PreferenceManager(P, U.id + "-preference-", loggingResponseHandler); 12 | -------------------------------------------------------------------------------- /bootstrap/src/scss.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.scss" { 2 | const content: any; 3 | export default content; 4 | } 5 | -------------------------------------------------------------------------------- /bootstrap/src/site.ts: -------------------------------------------------------------------------------- 1 | // This file cannot contain Webpack-resolved imports (e.g. "~src/foo"). 2 | 3 | import U from "./userscript"; 4 | 5 | export const NAME = U.sitename; 6 | export const HOSTNAME = U.hostname; 7 | -------------------------------------------------------------------------------- /bootstrap/src/stylesheets.ts: -------------------------------------------------------------------------------- 1 | import { ALWAYS } from "userscripter/run-time/environment"; 2 | import { Stylesheets, stylesheet } from "userscripter/run-time/stylesheets"; 3 | 4 | const STYLESHEETS = { 5 | main: stylesheet({ 6 | condition: ALWAYS, 7 | css: ` 8 | html body { 9 | background-color: rgb(144, 238, 144) !important; 10 | color: green !important; 11 | } 12 | `, 13 | }), 14 | } as const; 15 | 16 | // This trick uncovers type errors in STYLESHEETS while retaining the static knowledge of its properties (so we can still write e.g. STYLESHEETS.foo): 17 | const _: Stylesheets = STYLESHEETS; void _; 18 | 19 | export default STYLESHEETS; 20 | -------------------------------------------------------------------------------- /bootstrap/src/userscript.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | id: "bootstrapped-userscript", 3 | name: "Bootstrapped Userscript", 4 | version: "0.1.0", 5 | description: "A basic bootstrapped userscript.", 6 | author: "John Smith", 7 | hostname: "example.com", 8 | sitename: "Example.com", 9 | namespace: "mywebsite.example", 10 | runAt: "document-start", 11 | } as const; 12 | -------------------------------------------------------------------------------- /bootstrap/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "baseUrl": ".", 5 | "esModuleInterop": true, 6 | "jsx": "react", 7 | "jsxFactory": "h", 8 | "lib": [ 9 | "esnext", 10 | "dom", 11 | ], 12 | "module": "es2015", 13 | "moduleResolution": "node", 14 | "paths": { 15 | "~src/*": [ "./src/*" ], 16 | }, 17 | "preserveConstEnums": true, 18 | "skipLibCheck": true, 19 | "strict": true, 20 | "target": "es6", 21 | }, 22 | "include": [ 23 | "./src/*.d.ts", 24 | "./src/**/*.tsx", 25 | "./src/**/*.ts", 26 | ], 27 | } 28 | -------------------------------------------------------------------------------- /bootstrap/tsconfig.webpack.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "outDir": ".userscripter-temp", 6 | }, 7 | "include": [ 8 | "webpack.config.ts", 9 | ], 10 | } 11 | -------------------------------------------------------------------------------- /bootstrap/webpack.config.ts: -------------------------------------------------------------------------------- 1 | import * as AppRootPath from "app-root-path"; 2 | import { 3 | createWebpackConfig, 4 | DEFAULT_BUILD_CONFIG, 5 | DEFAULT_METADATA_SCHEMA, 6 | } from "userscripter/build-time"; 7 | 8 | import METADATA from "./metadata"; 9 | import * as CONFIG from "./src/config"; 10 | import * as SITE from "./src/site"; 11 | import U from "./src/userscript"; 12 | 13 | export default createWebpackConfig({ 14 | buildConfig: { 15 | ...DEFAULT_BUILD_CONFIG({ 16 | rootDir: AppRootPath.path, 17 | id: U.id, 18 | now: new Date(), 19 | }), 20 | sassVariables: { CONFIG, SITE }, 21 | }, 22 | metadata: METADATA, 23 | metadataSchema: DEFAULT_METADATA_SCHEMA, 24 | env: process.env, 25 | }); 26 | -------------------------------------------------------------------------------- /jest.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | "testEnvironment": "jsdom", 3 | "transform": { 4 | "^.+\\.ts$": "ts-jest", 5 | }, 6 | "testRegex": ".+\\.test\\.ts$", 7 | "moduleFileExtensions": [ 8 | "ts", 9 | "js", 10 | ], 11 | }; 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "userscripter", 3 | "version": "7.0.0", 4 | "description": "Create userscripts in a breeze!", 5 | "keywords": [ 6 | "userscript", 7 | "user script", 8 | "browser", 9 | "extension" 10 | ], 11 | "author": { 12 | "name": "Simon Alling", 13 | "email": "alling.simon@gmail.com", 14 | "url": "https://simonalling.se" 15 | }, 16 | "license": "MIT", 17 | "homepage": "https://github.com/simonalling/userscripter", 18 | "repository": { 19 | "type": "git", 20 | "url": "https://github.com/simonalling/userscripter" 21 | }, 22 | "bugs": { 23 | "url": "https://github.com/simonalling/userscripter/issues" 24 | }, 25 | "main": "run-time/index", 26 | "exports": { 27 | ".": { 28 | "import": "./run-time/index.mjs", 29 | "require": "./run-time/index.js" 30 | }, 31 | "./build-time": { 32 | "import": "./build-time/index.mjs", 33 | "require": "./build-time/index.js" 34 | }, 35 | "./run-time/*": { 36 | "import": "./run-time/*.mjs", 37 | "require": "./run-time/*.js" 38 | } 39 | }, 40 | "typesVersions": { 41 | "*": { 42 | "build-time": [ 43 | "./build-time/index.d.ts" 44 | ], 45 | "run-time/*": [ 46 | "./run-time/*.d.ts" 47 | ], 48 | "*": [ 49 | "This prevents imports of internal modules from compiling in TypeScript." 50 | ] 51 | } 52 | }, 53 | "files": [ 54 | "build-time/*", 55 | "run-time/*" 56 | ], 57 | "scripts": { 58 | "build-cjs": "npm run clean && npm run compile-cjs", 59 | "build-esm": "npm run clean && npm run compile-esm", 60 | "compile-cjs": "tsc -d --module CommonJS -p .", 61 | "compile-esm": "tsc -d -p .", 62 | "build": "npm run clean && npm run compile-esm && npm run rename && npm run compile-cjs", 63 | "clean": "rimraf run-time build-time", 64 | "lint": "eslint . --ext .ts", 65 | "prepublishOnly": "npm run verify && cli-confirm 'Publish?'", 66 | "rename": "renamer --force --find \"/\\.js$/\" --replace \".mjs\" \"run-time/**\" \"build-time/**\"", 67 | "test": "npm run lint && npm run jest", 68 | "jest": "jest --config ./jest.config.mjs", 69 | "verify": "npm ci && repository-check-dirty && npm run build && npm test && npm pack" 70 | }, 71 | "sideEffects": false, 72 | "devDependencies": { 73 | "@types/jest": "29.5.12", 74 | "@types/terser-webpack-plugin": "2.2.0", 75 | "@typescript-eslint/eslint-plugin": "^5.62.0", 76 | "@typescript-eslint/parser": "^5.62.0", 77 | "cli-confirm": "^1.0.1", 78 | "eslint": "^7.25.0", 79 | "eslint-plugin-import": "^2.22.1", 80 | "jest": "29.7.0", 81 | "jest-environment-jsdom": "29.7.0", 82 | "renamer": "^1.1.4", 83 | "repository-check-dirty": "^1.0.2", 84 | "rimraf": "5.0.7", 85 | "ts-jest": "29.2.3", 86 | "ts-preferences": "^2.0.0", 87 | "typescript": "5.0.4" 88 | }, 89 | "peerDependencies": { 90 | "ts-preferences": "^2.0.0" 91 | }, 92 | "dependencies": { 93 | "@types/node": "16.18.31", 94 | "@types/sass": "^1.16.0", 95 | "@types/webpack": "^4.41.38", 96 | "css-loader": "^3.2.0", 97 | "lines-unlines": "^1.0.0", 98 | "node-sass-utils": "^1.1.3", 99 | "raw-loader": "^4.0.0", 100 | "sass": "^1.32.8", 101 | "sass-loader": "10.1.1", 102 | "terser-webpack-plugin": "^2.3.1", 103 | "to-string-loader": "^1.1.6", 104 | "ts-loader": "^8.4.0", 105 | "ts-type-guards": "^0.6.1", 106 | "tsconfig-paths-webpack-plugin": "^3.2.0", 107 | "userscript-metadata": "^1.0.0", 108 | "webextension-manifest": "^1.0.0", 109 | "webpack": "^4.47.0" 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/build-time/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | BuildConfig, 3 | WebpackConfigParameters, 4 | distFileName, 5 | metadataUrl, 6 | } from "./internal/configuration"; 7 | 8 | export { 9 | Mode, 10 | } from "./internal/mode"; 11 | 12 | export { 13 | DEFAULT_BUILD_CONFIG, 14 | DEFAULT_METADATA_SCHEMA, 15 | createWebpackConfig, 16 | } from "./internal/webpack"; 17 | -------------------------------------------------------------------------------- /src/build-time/internal/configuration.ts: -------------------------------------------------------------------------------- 1 | import * as Metadata from "userscript-metadata"; 2 | import Manifest from "webextension-manifest"; 3 | 4 | import { Mode, isMode } from "./mode"; 5 | import { 6 | ParseResult, 7 | Parser, 8 | booleanParser, 9 | enumParser, 10 | urlParser, 11 | } from "./parsing"; 12 | 13 | const ENV_VAR_PREFIX = "USERSCRIPTER_"; 14 | 15 | export const HOSTED_AT_EXAMPLE = "https://example.com/userscripts"; 16 | 17 | export type BuildConfig = Readonly<{ 18 | appendDateToVersion: { 19 | development: boolean 20 | nightly: boolean 21 | production: boolean 22 | } 23 | id: string 24 | hostedAt: string | null 25 | mainFile: string 26 | mode: Mode 27 | nightly: boolean 28 | now: Date 29 | outDir: string 30 | rootDir: string 31 | sassVariableGetter: string 32 | sassVariables: Record 33 | sourceDir: string 34 | verbose: boolean 35 | }>; 36 | 37 | export type WebpackConfigParameters = Readonly<{ 38 | buildConfig: BuildConfig 39 | manifest?: (buildConfig: BuildConfig) => Manifest 40 | metadata: (buildConfig: BuildConfig) => Metadata.Metadata 41 | metadataSchema: Metadata.ValidateOptions 42 | env: NodeJS.ProcessEnv 43 | }>; 44 | 45 | type FromEnv = ParseResult | Readonly<{ 46 | kind: "undefined" 47 | }>; 48 | 49 | type EnvVarNameWithoutPrefix = keyof typeof ENVIRONMENT_VARIABLES; 50 | 51 | type EnvVarSpec = Readonly<{ 52 | nameWithoutPrefix: N 53 | parser: Parser 54 | overrides: K 55 | mustBe: string | readonly string[] // plaintext description (e.g. "a positive integer") or list of allowed values 56 | }>; 57 | 58 | export type EnvVarError = Readonly<{ 59 | fullName: string 60 | expected: string | readonly string[] 61 | found: string 62 | }>; 63 | 64 | export function envVarName(nameWithoutPrefix: EnvVarNameWithoutPrefix): string { 65 | return ENV_VAR_PREFIX + nameWithoutPrefix; 66 | } 67 | 68 | export const ENVIRONMENT_VARIABLES = { 69 | // `name` should NOT include "USERSCRIPTER_" prefix. 70 | // `overrides` must be in `keyof BuildConfig`. 71 | MODE: { 72 | nameWithoutPrefix: "MODE", 73 | parser: enumParser(isMode), 74 | overrides: "mode", 75 | mustBe: Object.values(Mode), 76 | }, 77 | NIGHTLY: { 78 | nameWithoutPrefix: "NIGHTLY", 79 | parser: booleanParser, 80 | overrides: "nightly", 81 | mustBe: ["true", "false"], 82 | }, 83 | HOSTED_AT: { 84 | nameWithoutPrefix: "HOSTED_AT", 85 | parser: urlParser, 86 | overrides: "hostedAt", 87 | mustBe: `a valid URL (e.g. "${HOSTED_AT_EXAMPLE}")`, 88 | }, 89 | VERBOSE: { 90 | nameWithoutPrefix: "VERBOSE", 91 | parser: booleanParser, 92 | overrides: "verbose", 93 | mustBe: ["true", "false"], 94 | }, 95 | } as const; 96 | 97 | { 98 | // A hack to make it easier to find type errors in ENVIRONMENT_VARIABLES. 99 | // It cannot have an explicit type itself since we want it to be `const`. 100 | const typecheckedEnvVars: { [N in EnvVarNameWithoutPrefix]: EnvVarSpec } = ENVIRONMENT_VARIABLES; 101 | void typecheckedEnvVars; // eslint-disable-line no-unused-expressions 102 | } 103 | 104 | export type BuildConfigAndListOf = Readonly<{ 105 | buildConfig: BuildConfig 106 | errors: readonly E[] 107 | }>; 108 | 109 | type DistFileType = "user" | "meta"; 110 | 111 | export function distFileName(id: string, type: DistFileType): string { 112 | return [ id, type, "js" ].join("."); 113 | } 114 | 115 | export function metadataUrl(hostedAt: string, id: string, type: DistFileType): string { 116 | return hostedAt.replace(/\/?$/, "/") + distFileName(id, type); 117 | } 118 | 119 | export function envVars(env: NodeJS.ProcessEnv): ReadonlyArray { 120 | return Object.values(ENVIRONMENT_VARIABLES).map(e => { 121 | const name = envVarName(e.nameWithoutPrefix); 122 | return [ name, env[name] ] as const; 123 | }); 124 | } 125 | 126 | export function overrideBuildConfig( 127 | buildConfig: BuildConfig, 128 | env: NodeJS.ProcessEnv, 129 | ): BuildConfigAndListOf { 130 | return Object.values(ENVIRONMENT_VARIABLES).reduce( 131 | (acc: BuildConfigAndListOf, envVar: EnvVarSpec) => { 132 | const envVarNameWithPrefix = envVarName(envVar.nameWithoutPrefix); 133 | const parsed = fromEnv(envVar, env[envVarNameWithPrefix]); 134 | switch (parsed.kind) { 135 | case "undefined": 136 | return acc; 137 | case "valid": 138 | return { 139 | ...acc, 140 | buildConfig: { 141 | ...acc.buildConfig, 142 | [envVar.overrides]: parsed.value, 143 | }, 144 | }; 145 | case "invalid": 146 | return { 147 | ...acc, 148 | errors: acc.errors.concat({ 149 | fullName: envVarNameWithPrefix, 150 | expected: envVar.mustBe, 151 | found: parsed.input, 152 | }), 153 | }; 154 | } 155 | }, 156 | { buildConfig, errors: [] }, 157 | ); 158 | } 159 | 160 | function fromEnv( 161 | envVarSpec: EnvVarSpec, 162 | v: string | undefined, 163 | ): FromEnv { 164 | return ( 165 | v === undefined 166 | ? { kind: "undefined" } 167 | : ( 168 | envVarSpec.parser(v) 169 | ) 170 | ); 171 | } 172 | -------------------------------------------------------------------------------- /src/build-time/internal/messages.ts: -------------------------------------------------------------------------------- 1 | import { unlines } from "lines-unlines"; 2 | import { isString } from "ts-type-guards"; 3 | import { Kind, ValidationError, Warning, tag } from "userscript-metadata"; 4 | 5 | import { BuildConfig, EnvVarError } from "./configuration"; 6 | import { BuildConfigError } from "./validation"; 7 | 8 | const webpackifyMessage = (context: string) => (s: string) => context + "\n" + s; 9 | 10 | const webpackifyMessage_environment = webpackifyMessage("environment"); 11 | 12 | const webpackifyMessage_metadata = webpackifyMessage("metadata"); 13 | 14 | const webpackifyMessage_buildConfig = webpackifyMessage("build configuration"); 15 | 16 | const webpackifyMessage_userscripter = webpackifyMessage("Userscripter"); 17 | 18 | export const envVarError = (e: EnvVarError) => ( 19 | webpackifyMessage_environment(invalidValue( 20 | `environment variable ${e.fullName}`, 21 | isString(e.expected) ? e.expected : oneOf(e.expected), 22 | quote(e.found), 23 | )) 24 | ); 25 | 26 | export const buildConfigError = (e: BuildConfigError) => ( 27 | webpackifyMessage_buildConfig(invalidValue( 28 | `parameter ${e.name}`, 29 | e.expected, 30 | e.found === null ? "null" : JSON.stringify(e.found), 31 | )) 32 | ); 33 | 34 | const invalidValue = (what: string, expected: string, found: string) => unlines([ 35 | `Invalid value for ${what}.`, 36 | ` • Expected: ${expected}`, 37 | ` • Found: ${found}`, 38 | ]); 39 | 40 | export const oneOf = (xs: readonly string[]) => { 41 | // Length of xs must be at least 1. 42 | const quoted = xs.map(quote); 43 | const allButLast = ( 44 | quoted.length > 1 45 | ? quoted.slice(0, quoted.length - 1).join(", ") + " or " 46 | : "" 47 | ); 48 | return allButLast + quoted[quoted.length - 1]; 49 | }; 50 | 51 | export const metadataWarning = (warning: Warning) => ( 52 | webpackifyMessage_metadata(unlines([ 53 | warning.summary, 54 | "", 55 | warning.description, 56 | ])) 57 | ); 58 | 59 | export const metadataError = (error: ValidationError) => ( 60 | webpackifyMessage_metadata((() => { 61 | switch (error.kind) { 62 | case Kind.INVALID_KEY: return `Invalid key: "${error.entry.key}". ${error.reason}`; 63 | case Kind.INVALID_VALUE: return `Invalid ${tag(error.entry.key)} value: ${JSON.stringify(error.entry.value)}. ${error.reason}`; 64 | case Kind.MULTIPLE_UNIQUE: return `Multiple ${tag(error.item.key)} values. Only one value is allowed.`; 65 | case Kind.REQUIRED_MISSING: return `A ${tag(error.item.key)} entry is required, but none was found.`; 66 | case Kind.UNRECOGNIZED_KEY: return `Unrecognized key: "${error.entry.key}".`; 67 | } 68 | })()) 69 | ); 70 | 71 | export const quote = (s: string) => `"${s}"`; 72 | 73 | export const compilationAssetNotFound = (assetName: string) => ( 74 | webpackifyMessage_userscripter(`Compilation asset ${quote(assetName)} expected but not found.`) 75 | ); 76 | -------------------------------------------------------------------------------- /src/build-time/internal/mode.ts: -------------------------------------------------------------------------------- 1 | import { isString } from "ts-type-guards"; 2 | 3 | export const Mode = { 4 | production: "production", 5 | development: "development", 6 | } as const; 7 | 8 | export type Mode = keyof typeof Mode; 9 | 10 | export function isMode(x: unknown): x is Mode { 11 | return isString(x) && (Object.values(Mode) as string[]).includes(x); 12 | } 13 | -------------------------------------------------------------------------------- /src/build-time/internal/parsing.ts: -------------------------------------------------------------------------------- 1 | import { TypeGuard } from "ts-type-guards"; 2 | 3 | export type ParseResult = Readonly<{ 4 | kind: "valid", value: T 5 | } | { 6 | kind: "invalid", input: string 7 | }>; 8 | 9 | export type Parser = (input: string) => ParseResult; 10 | 11 | export function enumParser(typeGuard: TypeGuard) { 12 | return (input: string): ParseResult => ( 13 | typeGuard(input) 14 | ? { kind: "valid", value: input } 15 | : { kind: "invalid", input: input } 16 | ); 17 | } 18 | 19 | export function booleanParser(input: string): ParseResult { 20 | return ( 21 | input === "true" 22 | ? { kind: "valid", value: true } 23 | : ( 24 | input === "false" 25 | ? { kind: "valid", value: false } 26 | : { kind: "invalid", input: input } 27 | ) 28 | ); 29 | } 30 | 31 | export function urlParser(input: string): ParseResult { 32 | try { 33 | return { kind: "valid", value: new URL(input).toString() }; 34 | } catch (_) { 35 | return { kind: "invalid", input: input }; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/build-time/internal/sass.ts: -------------------------------------------------------------------------------- 1 | import node_sass_utils from "node-sass-utils"; 2 | import sass from "sass"; 3 | import { isString } from "ts-type-guards"; 4 | 5 | const SassUtils = node_sass_utils(sass); 6 | 7 | export function getGlobalFrom(objectToBeExposedToSass: Record): (keyString: sass.types.SassType) => sass.types.SassType { 8 | const sassVars = toSassDimension_recursively(objectToBeExposedToSass); 9 | return keyString => { 10 | if (keyString instanceof sass.types.String) { 11 | const wholeName = keyString.getValue(); 12 | return SassUtils.castToSass(dig(sassVars, wholeName.split("."), wholeName)); 13 | } else { 14 | throw new TypeError(`Expected a string as argument, but saw: ${keyString}`); 15 | } 16 | }; 17 | } 18 | 19 | /** 20 | * Dart Sass requires that functions be referred to as e.g. `f($x, $y)`, not just `f`. This function performs that encoding; for example, given `"foo"` and a function of arity 2, it returns `"foo($x0, $x1)"`. 21 | */ 22 | export function withDartSassEncodedParameters< 23 | Name extends string, 24 | Args extends unknown[], 25 | >( 26 | functionName: Name, 27 | f: (...args: Args) => sass.types.SassType, 28 | ): `${Name}(${string})` { 29 | const encodedArguments = new Array(f.length).fill(undefined).map((_, ix) => `$x${ix}`).join(", "); 30 | return `${functionName}(${encodedArguments})` as `${Name}(${string})`; 31 | } 32 | 33 | function toSassDimension(s: string): SassDimension { 34 | const CSS_UNITS = [ "rem", "em", "vh", "vw", "vmin", "vmax", "ex", "%", "px", "cm", "mm", "in", "pt", "pc", "ch" ]; 35 | const parts = s.match(/^([.0-9]+)([a-zA-Z]+)$/); 36 | if (parts === null) { 37 | return s; 38 | } 39 | const number = parts[1]; 40 | const unit = parts[2]; 41 | if (number === undefined || unit === undefined) { 42 | return s; 43 | } 44 | if (CSS_UNITS.includes(unit)) { 45 | return new SassUtils.SassDimension(parseInt(number, 10), unit); 46 | } 47 | return s; 48 | } 49 | 50 | function toSassDimension_recursively(x: any): any { 51 | if (isString(x)) { 52 | return toSassDimension(x); 53 | } else if (typeof x === "object") { 54 | const result: any = {}; 55 | Object.keys(x).forEach(key => { 56 | result[key] = toSassDimension_recursively(x[key]); 57 | }); 58 | return result; 59 | } else { 60 | return x; 61 | } 62 | } 63 | 64 | function dig(obj: any, keys: string[], wholeName: string): any { 65 | if (keys[0] === undefined) { 66 | return obj; 67 | } else { 68 | const deeper = obj[keys[0]]; 69 | if (deeper === undefined) { 70 | throw new Error(`Unknown global: '${wholeName}' (failed on '${keys[0]}')`); 71 | } 72 | return dig(deeper, keys.slice(1), wholeName); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/build-time/internal/utilities.ts: -------------------------------------------------------------------------------- 1 | export function concat(xss: ReadonlyArray>): ReadonlyArray { 2 | return ([] as ReadonlyArray).concat(...xss); 3 | } 4 | -------------------------------------------------------------------------------- /src/build-time/internal/validation.ts: -------------------------------------------------------------------------------- 1 | import { BuildConfig, HOSTED_AT_EXAMPLE } from "./configuration"; 2 | import { urlParser } from "./parsing"; 3 | 4 | export type BuildConfigError = Readonly<{ 5 | name: K 6 | expected: string 7 | found: BuildConfig[K] 8 | }>; 9 | 10 | export type BuildConfigValidators = Readonly<{ 11 | [k in keyof BuildConfig]: PredicateWithDescription 12 | }>; 13 | 14 | type PredicateWithDescription = Readonly<{ 15 | predicate: (x: T) => boolean 16 | description: string 17 | }>; 18 | 19 | function requirement(x: { 20 | description: string 21 | key: K 22 | value: BuildConfig[K] 23 | predicate: (value: BuildConfig[K]) => boolean 24 | }) { 25 | return { ...x, valid: x.predicate(x.value) }; 26 | } 27 | 28 | function isValidId(x: string): boolean { 29 | return /^[a-z][a-z0-9-]*$/.test(x); 30 | } 31 | 32 | export function buildConfigErrors( 33 | buildConfig: BuildConfig, 34 | ): ReadonlyArray> { 35 | const REQUIREMENTS = [ 36 | requirement({ 37 | description: `a valid URL (e.g. "${HOSTED_AT_EXAMPLE}") or null`, 38 | key: "hostedAt", 39 | value: buildConfig.hostedAt, 40 | predicate: x => x === null || urlParser(x).kind === "valid", 41 | }), 42 | requirement({ 43 | description: `a string containing only lowercase letters (a–z), digits and hyphens (e.g. "example-userscript"), starting with a letter`, 44 | key: "id", 45 | value: buildConfig.id, 46 | predicate: isValidId, 47 | }), 48 | ] as const; 49 | return REQUIREMENTS.filter(x => !x.valid).map(x => ({ name: x.key, expected: x.description, found: x.value })); 50 | } 51 | -------------------------------------------------------------------------------- /src/build-time/internal/webpack-plugin.ts: -------------------------------------------------------------------------------- 1 | import * as Metadata from "userscript-metadata"; 2 | import Manifest from "webextension-manifest"; 3 | import * as webpack from "webpack"; 4 | import { RawSource, Source } from "webpack-sources"; 5 | 6 | import { 7 | BuildConfig, 8 | ENVIRONMENT_VARIABLES, 9 | EnvVarError, 10 | distFileName, 11 | envVarName, 12 | } from "./configuration"; 13 | import * as Msg from "./messages"; 14 | import { BuildConfigError } from "./validation"; 15 | 16 | const MANIFEST_FILE = "manifest.json"; 17 | const MANIFEST_INDENTATION = 2; 18 | 19 | export class UserscripterWebpackPlugin { 20 | constructor(private readonly x: { 21 | buildConfigErrors: ReadonlyArray> 22 | envVarErrors: readonly EnvVarError[] 23 | envVars: ReadonlyArray 24 | manifest: Manifest | undefined 25 | metadataStringified: string 26 | metadataValidationResult: Metadata.ValidationResult 27 | overriddenBuildConfig: BuildConfig 28 | verbose: boolean 29 | }) {} 30 | 31 | public apply(compiler: webpack.Compiler) { 32 | const { 33 | buildConfigErrors, 34 | envVarErrors, 35 | envVars, 36 | metadataStringified, 37 | metadataValidationResult, 38 | manifest, 39 | overriddenBuildConfig, 40 | verbose, 41 | } = this.x; 42 | const metadataAssetName = distFileName(overriddenBuildConfig.id, "meta"); 43 | const userscriptAssetName = distFileName(overriddenBuildConfig.id, "user"); 44 | compiler.hooks.afterCompile.tap( 45 | UserscripterWebpackPlugin.name, 46 | compilation => { 47 | // Create metadata file: 48 | compilation.assets[metadataAssetName] = new RawSource(metadataStringified); 49 | // Prepend metadata to compiled userscript (must be done after minification so metadata isn't removed in production mode): 50 | const compiledUserscript = compilation.assets[userscriptAssetName] as Source | undefined; 51 | if (compiledUserscript !== undefined) { // `instanceof RawSource` and `instanceof Source` don't work. 52 | compilation.assets[userscriptAssetName] = new RawSource( 53 | metadataStringified + "\n" + compiledUserscript.source(), 54 | ); 55 | } else { 56 | compilation.errors.push(Msg.compilationAssetNotFound(userscriptAssetName)); 57 | } 58 | // Create manifest file if requested: 59 | if (manifest !== undefined) { 60 | compilation.assets[MANIFEST_FILE] = new RawSource( 61 | JSON.stringify(manifest, null, MANIFEST_INDENTATION), 62 | ); 63 | } 64 | }, 65 | ); 66 | compiler.hooks.afterEmit.tap( 67 | UserscripterWebpackPlugin.name, 68 | compilation => { 69 | const logger = compilation.getLogger(UserscripterWebpackPlugin.name); 70 | function logWithHeading(heading: string, subject: any) { 71 | logger.log(" "); 72 | logger.log(heading); 73 | logger.log(subject); 74 | } 75 | compilation.errors.push(...envVarErrors.map(e => Error(Msg.envVarError(e)))); 76 | compilation.errors.push(...buildConfigErrors.map(e => Error(Msg.buildConfigError(e)))); 77 | if (Metadata.isLeft(metadataValidationResult)) { 78 | compilation.errors.push(...metadataValidationResult.Left.map(e => Error(Msg.metadataError(e)))); 79 | } else { 80 | compilation.warnings.push(...metadataValidationResult.Right.warnings.map(Msg.metadataWarning)); 81 | } 82 | if (verbose) { 83 | const envVarLines = envVars.map( 84 | ([ name, value ]) => " " + name + ": " + (value === undefined ? "(not specified)" : value), 85 | ); 86 | logWithHeading( 87 | "Environment variables:", 88 | envVarLines.join("\n"), 89 | ); 90 | logWithHeading( 91 | "Effective build config (after considering environment variables):", 92 | overriddenBuildConfig, 93 | ); 94 | logger.log(" "); // The empty string is not logged at all. 95 | } else { 96 | const hasUserscripterErrors = ( 97 | [ envVarErrors, buildConfigErrors ].some(_ => _.length > 0) 98 | || Metadata.isLeft(metadataValidationResult) 99 | ); 100 | if (hasUserscripterErrors) { 101 | const fullEnvVarName = envVarName(ENVIRONMENT_VARIABLES.VERBOSE.nameWithoutPrefix); 102 | logger.info(`Hint: Use ${fullEnvVarName}=true to display more information.`); 103 | } 104 | } 105 | // Log metadata: 106 | if (!compilation.getStats().hasErrors()) { 107 | const metadataAsset: unknown = compilation.assets[metadataAssetName]; 108 | if (metadataAsset instanceof RawSource) { 109 | logger.info(metadataAsset.source()); 110 | } else { 111 | compilation.warnings.push(Msg.compilationAssetNotFound(metadataAssetName)); 112 | } 113 | } 114 | }, 115 | ); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/build-time/internal/webpack.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | 3 | import TerserPlugin from "terser-webpack-plugin"; 4 | import TsconfigPathsPlugin from "tsconfig-paths-webpack-plugin"; 5 | import * as Metadata from "userscript-metadata"; 6 | import * as webpack from "webpack"; 7 | 8 | import { 9 | BuildConfig, 10 | WebpackConfigParameters, 11 | distFileName, 12 | envVars, 13 | overrideBuildConfig, 14 | } from "./configuration"; 15 | import { Mode } from "./mode"; 16 | import { getGlobalFrom, withDartSassEncodedParameters } from "./sass"; 17 | import { concat } from "./utilities"; 18 | import { buildConfigErrors } from "./validation"; 19 | import { UserscripterWebpackPlugin } from "./webpack-plugin"; 20 | 21 | const EXTENSIONS = { 22 | TS: ["ts", "tsx"], 23 | JS: ["mjs", "js", "jsx"], // Order is important: mjs must come before js to enable tree-shaking for dual-mode ESM/CJS packages. 24 | SASS: ["scss"], 25 | SVG: ["svg"], 26 | } as const; 27 | 28 | export const DEFAULT_BUILD_CONFIG: (x: { 29 | rootDir: string 30 | id: string 31 | now: Date 32 | }) => BuildConfig = x => ({ 33 | appendDateToVersion: { 34 | development: true, 35 | nightly: true, 36 | production: false, 37 | }, 38 | id: x.id, 39 | hostedAt: null, 40 | mainFile: "main.ts", 41 | mode: Mode.development, 42 | nightly: false, 43 | now: x.now, 44 | outDir: "dist", 45 | rootDir: x.rootDir, 46 | sassVariableGetter: "getGlobal", 47 | sassVariables: {}, 48 | sourceDir: "src", 49 | verbose: false, 50 | }); 51 | 52 | export const DEFAULT_METADATA_SCHEMA: Metadata.ValidateOptions = { 53 | items: { 54 | ...Metadata.DEFAULT_ITEMS, 55 | version: Metadata.DEFAULT_ITEMS.version.butRequired(), // Validated against a subset of SemVer by default. 56 | run_at: Metadata.DEFAULT_ITEMS.run_at.butRequired(), 57 | }, 58 | warnings: Metadata.DEFAULT_WARNINGS, 59 | underscoresAsHyphens: true, 60 | } as const; 61 | 62 | export function createWebpackConfig(x: WebpackConfigParameters): webpack.Configuration { 63 | const overridden = overrideBuildConfig(x.buildConfig, x.env); 64 | const { 65 | appendDateToVersion, 66 | id, 67 | mainFile, 68 | mode, 69 | nightly, 70 | now, 71 | outDir, 72 | rootDir, 73 | sassVariableGetter, 74 | sassVariables, 75 | sourceDir, 76 | verbose, 77 | } = overridden.buildConfig; 78 | const getGlobal = getGlobalFrom(sassVariables); 79 | function finalName(name: string): string { 80 | return name + (nightly ? " Nightly" : ""); 81 | } 82 | function finalVersion(version: string): string { 83 | switch (true) { 84 | case nightly && appendDateToVersion.nightly: 85 | case mode === Mode.development && appendDateToVersion.development: 86 | case mode === Mode.production && appendDateToVersion.production: 87 | return version + "." + dateAsSemver(now); 88 | default: 89 | return version; 90 | } 91 | } 92 | const finalMetadata = (() => { 93 | const unfinishedMetadata = x.metadata(overridden.buildConfig); 94 | return { 95 | ...unfinishedMetadata, 96 | name: finalName(unfinishedMetadata["name"] as string), 97 | version: finalVersion(unfinishedMetadata["version"] as string), 98 | }; 99 | })(); 100 | const finalManifest = x.manifest === undefined ? undefined : (() => { 101 | const unfinishedManifest = x.manifest(overridden.buildConfig); 102 | return { 103 | ...unfinishedManifest, 104 | name: finalName(unfinishedManifest.name), 105 | version: finalVersion(unfinishedManifest.version), 106 | }; 107 | })(); 108 | const finalMetadataStringified = Metadata.stringify(finalMetadata); 109 | return { 110 | mode: mode, 111 | entry: { 112 | userscript: resolveIn(sourceDir)(mainFile), 113 | }, 114 | output: { 115 | path: resolveIn(rootDir)(outDir), 116 | filename: distFileName(id, "user"), 117 | }, 118 | devtool: mode === Mode.production ? "hidden-source-map" : "inline-cheap-source-map", 119 | stats: { 120 | depth: false, 121 | hash: false, 122 | modules: false, 123 | entrypoints: false, 124 | colors: true, 125 | logging: verbose ? "verbose" : "info", 126 | } as webpack.Stats.ToStringOptionsObject, // because the `logging` property is not recognized 127 | module: { 128 | rules: [ 129 | { 130 | test: filenameExtensionRegex(EXTENSIONS.SVG), 131 | loaders: [ 132 | { 133 | loader: require.resolve("raw-loader"), 134 | }, 135 | ], 136 | }, 137 | { 138 | test: filenameExtensionRegex(EXTENSIONS.SASS), 139 | loaders: [ 140 | // Loaders must be require.resolved here so that Webpack is guaranteed to find them. 141 | { 142 | loader: require.resolve("to-string-loader"), 143 | }, 144 | { 145 | loader: require.resolve("css-loader"), 146 | options: { 147 | url: true, 148 | import: true, 149 | sourceMap: false, 150 | modules: { 151 | auto: undefined, 152 | mode: "local", 153 | exportGlobals: false, 154 | localIdentName: "[local]", 155 | context: undefined, 156 | hashPrefix: "", // Documented default is undefined, but actual default seems to be "" based on source code (in css-loader v3.6.0). 157 | // getLocalIdent: Documented default is undefined, but that doesn't work (in css-loader v3.6.0). 158 | localIdentRegExp: undefined, // Documented default is undefined, but actual default seems to be null based on source code, but null is not allowed (in css-loader v3.6.0). 159 | }, 160 | importLoaders: 0, 161 | localsConvention: "asIs", 162 | onlyLocals: false, 163 | esModule: false, 164 | }, 165 | }, 166 | { 167 | loader: require.resolve("sass-loader"), 168 | options: { 169 | sassOptions: { 170 | functions: { [withDartSassEncodedParameters(sassVariableGetter, getGlobal)]: getGlobal }, 171 | }, 172 | }, 173 | }, 174 | ], 175 | }, 176 | { 177 | test: filenameExtensionRegex(EXTENSIONS.TS), 178 | include: resolveIn(rootDir)(sourceDir), 179 | loaders: [ 180 | { 181 | loader: require.resolve("ts-loader"), 182 | }, 183 | ], 184 | }, 185 | ], 186 | }, 187 | resolve: { 188 | plugins: [ 189 | new TsconfigPathsPlugin(), 190 | ], 191 | extensions: concat(Object.values(EXTENSIONS)).map(e => "." + e), 192 | }, 193 | plugins: [ 194 | new UserscripterWebpackPlugin({ 195 | buildConfigErrors: buildConfigErrors(overridden.buildConfig), 196 | envVarErrors: overridden.errors, 197 | envVars: envVars(x.env), 198 | metadataStringified: finalMetadataStringified, 199 | metadataValidationResult: Metadata.validateWith(x.metadataSchema)(finalMetadata), 200 | manifest: finalManifest, 201 | overriddenBuildConfig: overridden.buildConfig, 202 | verbose: verbose, 203 | }), 204 | // If we insert metadata with BannerPlugin, it is removed when building in production mode. 205 | ], 206 | optimization: { 207 | minimize: mode === Mode.production, 208 | minimizer: [ 209 | new TerserPlugin({ 210 | parallel: true, 211 | }), 212 | ], 213 | }, 214 | }; 215 | } 216 | 217 | const resolveIn = (root: string) => (subdir: string) => path.resolve(root, subdir); 218 | 219 | function filenameExtensionRegex(extensions: ReadonlyArray): RegExp { 220 | return new RegExp("\\.(" + extensions.join("|") + ")$"); 221 | } 222 | 223 | function dateAsSemver(d: Date): string { 224 | return [ 225 | d.getFullYear(), 226 | d.getMonth() + 1, // 0-indexed 227 | d.getDate(), 228 | d.getHours(), 229 | d.getMinutes(), 230 | ].join("."); 231 | } 232 | -------------------------------------------------------------------------------- /src/build-time/node-sass-utils.d.ts: -------------------------------------------------------------------------------- 1 | // This declaration file contains only what's required for Userscripter. 2 | 3 | declare module "node-sass-utils" { 4 | import sass from "sass"; 5 | export default function(sassInstance: typeof sass): { 6 | SassDimension: typeof SassDimension 7 | castToSass: (dug: any) => sass.types.SassType 8 | }; 9 | } 10 | 11 | declare class SassDimension { 12 | constructor(n: number, unit: string); 13 | } 14 | -------------------------------------------------------------------------------- /src/run-time/environment.ts: -------------------------------------------------------------------------------- 1 | /* 2 | This type is chosen so that side-effects can be deferred from the time of declaring operations to when they are executed. 3 | A prime example is if a condition should be based on the content of document.head: 4 | In some scenarios, e.g. a WebExtension running in Google Chrome, document.head is null when the operations are declared. 5 | */ 6 | export type Condition = (w: Window) => boolean; 7 | 8 | export const ALWAYS = () => true; 9 | export const NEVER = () => false; 10 | 11 | export const DOMCONTENTLOADED = (state: DocumentReadyState) => state !== "loading"; 12 | export const LOAD = (state: DocumentReadyState) => state === "complete"; 13 | -------------------------------------------------------------------------------- /src/run-time/errors.ts: -------------------------------------------------------------------------------- 1 | import { unlines } from "lines-unlines"; 2 | 3 | import { OperationAndFailure, Reason } from "./operations"; 4 | 5 | export type OperationContext = Readonly<{ 6 | siteName: string 7 | extensionName: string 8 | location: Location 9 | }>; 10 | 11 | const INDENTATION = " "; 12 | 13 | function formatDependency(d: { key: string, selector: string }): string { 14 | return INDENTATION + d.key + ": " + d.selector; 15 | } 16 | 17 | export function explanation(failure: OperationAndFailure): string { 18 | switch (failure.result.reason) { 19 | case Reason.DEPENDENCIES: 20 | return unlines([ 21 | `These dependencies were not found:`, 22 | ``, 23 | unlines(failure.result.dependencies.map(formatDependency)), 24 | ]); 25 | case Reason.INTERNAL: 26 | return unlines([ 27 | `The operation failed with this error:`, 28 | ``, 29 | failure.result.message, 30 | ]); 31 | } 32 | } 33 | 34 | export function failureDescriber(context: OperationContext): (failure: OperationAndFailure) => string { 35 | return failure => unlines([ 36 | `Could not ${failure.operation.description} on this page:`, 37 | ``, 38 | INDENTATION + location.href, 39 | ``, 40 | explanation(failure).trim(), 41 | ``, 42 | `This problem might be caused by ${context.siteName} changing its content/structure, in which case ${context.extensionName} needs to be updated accordingly. Otherwise, it's probably a bug in ${context.extensionName}.`, 43 | ``, 44 | `If you file a bug report, please include this message.`, 45 | ]); 46 | } 47 | -------------------------------------------------------------------------------- /src/run-time/index.ts: -------------------------------------------------------------------------------- 1 | import * as environment from "./environment"; 2 | import * as errors from "./errors"; 3 | import * as log from "./log"; 4 | import * as operations from "./operations"; 5 | import * as preferences from "./preferences"; 6 | import * as stylesheets from "./stylesheets"; 7 | import * as userscripter from "./userscripter"; 8 | 9 | export { 10 | environment, 11 | errors, 12 | log, 13 | operations, 14 | preferences, 15 | stylesheets, 16 | userscripter, 17 | }; 18 | -------------------------------------------------------------------------------- /src/run-time/log.ts: -------------------------------------------------------------------------------- 1 | type LoggerMethodName = "log" | "info" | "warn" | "error"; 2 | 3 | export type Logger = { 4 | readonly [K in LoggerMethodName]: (...xs: any[]) => void 5 | }; 6 | 7 | let prefix = ""; 8 | let logger: Logger = console; 9 | 10 | export function setPrefix(p: string): void { 11 | prefix = p; 12 | } 13 | 14 | export function setLogger(l: Logger): void { 15 | logger = l; 16 | } 17 | 18 | export function log(str: string): void { 19 | logger.log(prefix, str); 20 | } 21 | 22 | export function info(str: string): void { 23 | logger.info(prefix, str); 24 | } 25 | 26 | export function warning(str: string): void { 27 | logger.warn(prefix, str); 28 | } 29 | 30 | export function error(str: string): void { 31 | logger.error(prefix, str); 32 | } 33 | -------------------------------------------------------------------------------- /src/run-time/operations.ts: -------------------------------------------------------------------------------- 1 | import { isNull, isNumber, isString } from "ts-type-guards"; 2 | 3 | import { Condition } from "./environment"; 4 | 5 | type ActionResult = string | void; 6 | type OperationResult = OperationFailure | undefined; 7 | const SUCCESS = undefined; 8 | 9 | export const enum Reason { DEPENDENCIES, INTERNAL } 10 | 11 | export type OperationFailure = Readonly<{ 12 | reason: Reason.DEPENDENCIES 13 | dependencies: ReadonlyArray<{ key: string, selector: string }> 14 | } | { 15 | reason: Reason.INTERNAL 16 | message: string 17 | }>; 18 | 19 | export type OperationAndFailure = Readonly<{ 20 | operation: Operation 21 | result: OperationFailure 22 | }>; 23 | 24 | type BaseOperation = Readonly<{ 25 | condition: Condition 26 | description: string 27 | deferUntil?: (state: DocumentReadyState) => boolean 28 | }>; 29 | 30 | // The purpose of these types is to enforce the dependencies–action relationship. 31 | type DependentOperationSpec = BaseOperation & Readonly<{ 32 | dependencies: { [k in K]: string } 33 | action: (e: { [k in K]: HTMLElement }) => ActionResult 34 | }>; 35 | 36 | type IndependentOperationSpec = BaseOperation & Readonly<{ 37 | dependencies?: undefined 38 | action: () => ActionResult 39 | }>; 40 | 41 | export type Operation = DependentOperationSpec | IndependentOperationSpec; 42 | 43 | export function operation(spec: Operation): Operation { 44 | return spec as Operation; 45 | } 46 | 47 | export type FailuresHandler = (failures: ReadonlyArray>) => void; 48 | 49 | export type Plan = Readonly<{ 50 | operations: ReadonlyArray> 51 | interval: number // time between each try in milliseconds 52 | tryUntil: (state: DocumentReadyState) => boolean // when to stop trying 53 | extraTries: number // number of extra tries after tryUntil is satisfied 54 | handleFailures: FailuresHandler 55 | }>; 56 | 57 | export function run(plan: Plan): void { 58 | function recurse( 59 | operations: ReadonlyArray>, 60 | failures: Array>, 61 | triesLeft?: number, 62 | ): void { 63 | const lastTry = isNumber(triesLeft) && triesLeft <= 0; 64 | const operationsToRunNow: Array> = []; 65 | const remaining: Array> = []; 66 | const readyState = document.readyState; 67 | // Decide which operations to run now: 68 | for (const o of operations) { 69 | const shouldRunNow = o.deferUntil === undefined || o.deferUntil(readyState); 70 | (shouldRunNow ? operationsToRunNow : remaining).push(o); 71 | } 72 | // Run the operations and collect failures: 73 | for (const o of operationsToRunNow) { 74 | const result = tryToPerform(o); 75 | if (result !== SUCCESS) { 76 | switch (result.reason) { 77 | case Reason.DEPENDENCIES: 78 | if (lastTry) { 79 | failures.push({ result, operation: o }); 80 | } else { 81 | remaining.push(o); 82 | } 83 | break; 84 | case Reason.INTERNAL: 85 | failures.push({ result, operation: o }); 86 | break; 87 | } 88 | } 89 | } 90 | // Check how things went and act accordingly: 91 | if (remaining.length > 0) { 92 | setTimeout( 93 | () => recurse(remaining, failures, ( 94 | isNumber(triesLeft) 95 | ? triesLeft - 1 96 | : plan.tryUntil(readyState) ? plan.extraTries : undefined 97 | )), 98 | plan.interval, 99 | ); 100 | } else if (failures.length > 0) { 101 | plan.handleFailures(failures); 102 | } 103 | } 104 | 105 | recurse(plan.operations.filter(o => o.condition(window)), []); 106 | } 107 | 108 | function tryToPerform(o: Operation): OperationResult { 109 | const dependencies = o.dependencies === undefined ? {} as { [k in K]: string } : o.dependencies; 110 | const queryResults = Object.entries(dependencies).map(([ key, selector ]) => ({ 111 | key, selector, element: document.querySelector(selector), 112 | })); 113 | const missingDependencies = queryResults.filter(x => isNull(x.element)); 114 | if (missingDependencies.length > 0) { 115 | return { reason: Reason.DEPENDENCIES, dependencies: missingDependencies }; 116 | } 117 | const e = queryResults.reduce( 118 | (acc, x) => Object.defineProperty(acc, x.key, { value: x.element, enumerable: true }), 119 | {} as { [k in K]: HTMLElement }, 120 | ); 121 | return fromActionResult(o.action(e)); 122 | } 123 | 124 | function fromActionResult(r: ActionResult): OperationResult { 125 | return isString(r) ? { reason: Reason.INTERNAL, message: r } : SUCCESS; 126 | } 127 | -------------------------------------------------------------------------------- /src/run-time/preferences.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AllowedTypes, 3 | Preference, 4 | PreferenceManager, 5 | RequestSummary, 6 | Response, 7 | ResponseHandler, 8 | Status, 9 | } from "ts-preferences"; 10 | 11 | import * as log from "./log"; 12 | 13 | type Listener = (p: Preference) => void; 14 | 15 | export function subscriptable(handler: ResponseHandler): Readonly<{ 16 | subscribe: (listener: Listener) => void 17 | unsubscribe: (listener: Listener) => void 18 | handler: ResponseHandler 19 | }> { 20 | const changeListeners: Set> = new Set(); 21 | return { 22 | subscribe: (listener: Listener) => { changeListeners.add(listener); }, 23 | unsubscribe: (listener: Listener) => { changeListeners.delete(listener); }, 24 | handler: (summary, preferences) => { 25 | if (summary.action === "set") { 26 | changeListeners.forEach(f => f(summary.preference)); 27 | } 28 | return handler(summary, preferences); 29 | }, 30 | }; 31 | } 32 | 33 | export function loggingResponseHandler(summary: RequestSummary, preferences: PreferenceManager): Response { 34 | const response = summary.response; 35 | switch (response.status) { 36 | case Status.OK: 37 | return response; 38 | 39 | case Status.INVALID_VALUE: 40 | if (summary.action === "get") { 41 | // response.saved is defined if and only if action is "get" and status is INVALID_VALUE: 42 | log.warning(`The saved value for preference '${summary.preference.key}' (${JSON.stringify(response.saved)}) was invalid. Replacing it with ${JSON.stringify(response.value)}.`); 43 | preferences.set(summary.preference, response.value); 44 | } 45 | if (summary.action === "set") { 46 | log.warning(`Could not set value ${JSON.stringify(response.value)} for preference '${summary.preference.key}' because it was invalid.`); 47 | } 48 | return response; 49 | 50 | case Status.TYPE_ERROR: 51 | if (summary.action === "get") { 52 | log.warning(`The saved value for preference '${summary.preference.key}' had the wrong type. Replacing it with ${JSON.stringify(response.value)}.`); 53 | preferences.set(summary.preference, response.value); 54 | } 55 | return response; 56 | 57 | case Status.JSON_ERROR: 58 | if (summary.action === "get") { 59 | log.warning(`The saved value for preference '${summary.preference.key}' could not be parsed. Replacing it with ${JSON.stringify(response.value)}.`); 60 | preferences.set(summary.preference, response.value); 61 | } 62 | return response; 63 | 64 | case Status.STORAGE_ERROR: 65 | switch (summary.action) { 66 | case "get": 67 | log.error(`Could not read preference '${summary.preference.key}' because localStorage could not be accessed. Using value ${JSON.stringify(summary.preference.default)}.`); 68 | break; 69 | case "set": 70 | log.error(`Could not save value ${JSON.stringify(summary.response.value)} for preference '${summary.preference.key}' because localStorage could not be accessed.`); 71 | break; 72 | default: 73 | assertUnreachable(summary.action); 74 | } 75 | return response; 76 | 77 | default: 78 | return assertUnreachable(response.status); 79 | } 80 | } 81 | 82 | export function noopResponseHandler(summary: RequestSummary, _: PreferenceManager): Response { 83 | return summary.response; 84 | } 85 | 86 | function assertUnreachable(x: never): never { 87 | throw new Error("assertUnreachable: " + x); 88 | } 89 | -------------------------------------------------------------------------------- /src/run-time/stylesheets.ts: -------------------------------------------------------------------------------- 1 | import { Condition } from "./environment"; 2 | 3 | // CSS media queries: 4 | const MATCH_ALL = "all"; 5 | const MATCH_NONE = "not all"; 6 | 7 | type BaseStylesheet = Readonly<{ 8 | css: string 9 | condition: Condition 10 | }>; 11 | 12 | type StylesheetWithoutId = BaseStylesheet & Readonly<{ 13 | id?: undefined 14 | }>; 15 | 16 | type StylesheetWithId = BaseStylesheet & Readonly<{ 17 | id: string // necessary if and only if the stylesheet should be togglable 18 | }>; 19 | 20 | type Stylesheet = StylesheetWithId | StylesheetWithoutId; 21 | 22 | // Forces type errors at stylesheet declaration site, close to the userscript author: 23 | export function stylesheet(spec: StylesheetWithId): StylesheetWithId; 24 | export function stylesheet(spec: StylesheetWithoutId): StylesheetWithoutId; 25 | export function stylesheet(spec: Stylesheet): Stylesheet { 26 | return spec; 27 | } 28 | 29 | export type Stylesheets = Readonly<{ [_: string]: Stylesheet }>; 30 | 31 | export function insert(stylesheets: Stylesheets): void { 32 | const fragment = document.createDocumentFragment(); 33 | Object.entries(stylesheets).forEach(([ _, sheet ]) => { 34 | const style = document.createElement("style"); 35 | if (sheet.id !== undefined) style.id = sheet.id; 36 | style.textContent = sheet.css; 37 | style.media = sheet.condition(window) ? MATCH_ALL : MATCH_NONE; 38 | fragment.appendChild(style); 39 | }); 40 | document.documentElement.appendChild(fragment); 41 | } 42 | 43 | const setMediaQuery = (m: string) => (s: StylesheetWithId) => { 44 | const element = document.getElementById(s.id); 45 | if (element !== null) { 46 | element.setAttribute("media", m); 47 | } 48 | }; 49 | 50 | export const enable = setMediaQuery(MATCH_ALL); 51 | export const disable = setMediaQuery(MATCH_NONE); 52 | -------------------------------------------------------------------------------- /src/run-time/userscripter.ts: -------------------------------------------------------------------------------- 1 | import * as log from "./log"; 2 | import * as operations from "./operations"; 3 | import * as stylesheets from "./stylesheets"; 4 | 5 | export function run(userscript: { 6 | id: string, 7 | name: string, 8 | initialAction: () => void, 9 | stylesheets: stylesheets.Stylesheets, 10 | operationsPlan: operations.Plan, 11 | }): void { 12 | log.setPrefix(`[${userscript.name}]`); 13 | // Make sure the userscript does not run more than once (e.g. if it's installed 14 | // twice or if the browser uses a cached page when navigating back and forward): 15 | const attr = attribute(userscript.id); 16 | if (document.documentElement.hasAttribute(attr)) { 17 | log.warning(`It looks as though ${userscript.name} has already run (because the attribute "${attr}" was found on ). Stopping.`); 18 | } else { 19 | document.documentElement.setAttribute(attr, ""); 20 | userscript.initialAction(); 21 | stylesheets.insert(userscript.stylesheets); 22 | operations.run(userscript.operationsPlan); 23 | } 24 | } 25 | 26 | function attribute(id: string): string { 27 | return "data-" + id + "-has-run"; 28 | } 29 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": false, 4 | "allowSyntheticDefaultImports": true, 5 | "allowUnreachableCode": false, 6 | "allowUnusedLabels": false, 7 | "declaration": true, 8 | "emitDecoratorMetadata": true, 9 | "esModuleInterop": true, 10 | "exactOptionalPropertyTypes": true, 11 | "experimentalDecorators": true, 12 | "lib": [ 13 | "es2018", 14 | "dom", 15 | ], 16 | "module": "es2015", 17 | "moduleResolution": "node", 18 | "noFallthroughCasesInSwitch": true, 19 | "noImplicitReturns": true, 20 | "noPropertyAccessFromIndexSignature": true, 21 | "noUncheckedIndexedAccess": true, 22 | "noUnusedLocals": true, 23 | "noUnusedParameters": true, 24 | "outDir": ".", 25 | "removeComments": true, 26 | "rootDir": "src", 27 | "skipLibCheck": true, 28 | "strict": true, 29 | "target": "es2018", 30 | }, 31 | "exclude": [], 32 | "include": [ 33 | "src/**/*.ts", 34 | ], 35 | } 36 | --------------------------------------------------------------------------------