├── .nvmrc ├── .gitattributes ├── .github ├── FUNDING.yml ├── copilot-instructions.md └── workflows │ ├── ci.yml │ └── deploy-docs.yml ├── art ├── title.ttf ├── witchcraft.xcf ├── witchcraft-banner.png ├── witchcraft-banner.xcf ├── witchcraft-1280x800.png ├── witchcraft-440x280.png ├── witchcraft-440x280.xcf ├── witchcraft-920x680.png ├── witchcraft-920x680.xcf ├── witchcraft-128-with-borders.png ├── witchcraft-128-with-borders.xcf ├── README.md ├── witch-hat.svg ├── title.svg └── witch.svg ├── chrome-extension ├── content-script.js ├── witch-128.png ├── witch-16.png ├── witch-24.png ├── witch-32.png ├── witch-64.png ├── constants.js ├── analytics │ ├── index.js │ ├── events.js │ ├── metrics.js │ └── agent.js ├── script │ ├── include-context.js │ ├── script-context.js │ └── index.js ├── util │ ├── debouncer.js │ ├── embed-script.js │ ├── fetch-script.js │ └── index.js ├── badge.js ├── url.js ├── background.js ├── popup │ ├── popup.html │ ├── popup.css │ └── popup.js ├── manifest.json ├── storage │ ├── evict-stale.js │ └── index.js ├── path.js ├── icon.js ├── loader.js └── browser.js ├── test ├── integration │ ├── resources │ │ ├── test.css │ │ └── test.js │ ├── utils │ │ ├── resource-utils.js │ │ ├── dummy-web-server.js │ │ ├── dummy-script-server.js │ │ └── browser-test-utils.js │ └── sandbox.js ├── path │ ├── map-to-js-and-css.js │ ├── path-tuple-to-script-context.js │ ├── iterate-domain-levels.js │ ├── generate-potential-script-names.js │ └── iterate-path-segments.js ├── script │ ├── expand-include.js │ ├── process-include-directive.js │ └── find-include-directive.js ├── util │ ├── base-64-to-typed-array.js │ ├── splice-string.js │ ├── fetch-script.js │ └── embed-script.js ├── url │ ├── try-parse-url.js │ └── compose-url.js ├── storage │ └── frame-data.js ├── browser │ └── chrome-api │ │ ├── get-tab-url.js │ │ └── capture-runtime-error.js └── loader │ ├── load-includes.js │ ├── inject-script.js │ ├── load-scripts.js │ └── load-single-script.js ├── .gitignore ├── docs ├── src │ ├── assets │ │ ├── include.css │ │ ├── title.png │ │ ├── include-remote.js │ │ ├── screenshot.png │ │ ├── server-online.png │ │ ├── google.com.css │ │ ├── allow-user-scripts.png │ │ ├── manage-extension.png │ │ ├── ChromeWebStore_Badge_v2_340x96.png │ │ ├── google.com.js │ │ ├── include.js │ │ ├── witchcraft-server.sh │ │ └── witch-hat.svg │ ├── content │ │ └── docs │ │ │ ├── examples.mdx │ │ │ ├── cookbook │ │ │ ├── waiting-for-document-state.mdx │ │ │ ├── password.css │ │ │ └── password.mdx │ │ │ ├── new-in-v3.mdx │ │ │ ├── credits.mdx │ │ │ ├── index.mdx │ │ │ ├── wsl2-python-server.mdx │ │ │ ├── introduction.mdx │ │ │ ├── how-to-use.mdx │ │ │ ├── how-to-install.mdx │ │ │ ├── faq.mdx │ │ │ └── architecture.mdx │ ├── content.config.ts │ └── styles │ │ └── global.css ├── tsconfig.json ├── .gitignore ├── package.json ├── README.md └── astro.config.mjs ├── .dockerignore ├── README.md ├── Dockerfile ├── CHANGELOG.md ├── LICENSE ├── package.json └── development.md /.nvmrc: -------------------------------------------------------------------------------- 1 | v24.8.0 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | patreon: luciopaiva 2 | -------------------------------------------------------------------------------- /art/title.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luciopaiva/witchcraft/HEAD/art/title.ttf -------------------------------------------------------------------------------- /chrome-extension/content-script.js: -------------------------------------------------------------------------------- 1 | 2 | chrome.runtime.sendMessage(location); 3 | -------------------------------------------------------------------------------- /test/integration/resources/test.css: -------------------------------------------------------------------------------- 1 | 2 | h1 { 3 | color: rgb(0, 0, 255); 4 | } 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .nyc_output/ 3 | coverage/ 4 | credentials.json 5 | dist/ 6 | -------------------------------------------------------------------------------- /art/witchcraft.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luciopaiva/witchcraft/HEAD/art/witchcraft.xcf -------------------------------------------------------------------------------- /docs/src/assets/include.css: -------------------------------------------------------------------------------- 1 | .foo {} 2 | 3 | /* @include other.css */ 4 | 5 | .bar {} 6 | -------------------------------------------------------------------------------- /docs/src/content/docs/examples.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Examples 3 | --- 4 | 5 | Examples. 6 | -------------------------------------------------------------------------------- /art/witchcraft-banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luciopaiva/witchcraft/HEAD/art/witchcraft-banner.png -------------------------------------------------------------------------------- /art/witchcraft-banner.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luciopaiva/witchcraft/HEAD/art/witchcraft-banner.xcf -------------------------------------------------------------------------------- /docs/src/assets/title.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luciopaiva/witchcraft/HEAD/docs/src/assets/title.png -------------------------------------------------------------------------------- /art/witchcraft-1280x800.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luciopaiva/witchcraft/HEAD/art/witchcraft-1280x800.png -------------------------------------------------------------------------------- /art/witchcraft-440x280.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luciopaiva/witchcraft/HEAD/art/witchcraft-440x280.png -------------------------------------------------------------------------------- /art/witchcraft-440x280.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luciopaiva/witchcraft/HEAD/art/witchcraft-440x280.xcf -------------------------------------------------------------------------------- /art/witchcraft-920x680.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luciopaiva/witchcraft/HEAD/art/witchcraft-920x680.png -------------------------------------------------------------------------------- /art/witchcraft-920x680.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luciopaiva/witchcraft/HEAD/art/witchcraft-920x680.xcf -------------------------------------------------------------------------------- /docs/src/assets/include-remote.js: -------------------------------------------------------------------------------- 1 | // @include https://raw.githubusercontent.com/luciopaiva/foo/master/bar.js 2 | -------------------------------------------------------------------------------- /chrome-extension/witch-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luciopaiva/witchcraft/HEAD/chrome-extension/witch-128.png -------------------------------------------------------------------------------- /chrome-extension/witch-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luciopaiva/witchcraft/HEAD/chrome-extension/witch-16.png -------------------------------------------------------------------------------- /chrome-extension/witch-24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luciopaiva/witchcraft/HEAD/chrome-extension/witch-24.png -------------------------------------------------------------------------------- /chrome-extension/witch-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luciopaiva/witchcraft/HEAD/chrome-extension/witch-32.png -------------------------------------------------------------------------------- /chrome-extension/witch-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luciopaiva/witchcraft/HEAD/chrome-extension/witch-64.png -------------------------------------------------------------------------------- /docs/src/assets/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luciopaiva/witchcraft/HEAD/docs/src/assets/screenshot.png -------------------------------------------------------------------------------- /docs/src/assets/server-online.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luciopaiva/witchcraft/HEAD/docs/src/assets/server-online.png -------------------------------------------------------------------------------- /art/witchcraft-128-with-borders.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luciopaiva/witchcraft/HEAD/art/witchcraft-128-with-borders.png -------------------------------------------------------------------------------- /art/witchcraft-128-with-borders.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luciopaiva/witchcraft/HEAD/art/witchcraft-128-with-borders.xcf -------------------------------------------------------------------------------- /docs/src/assets/google.com.css: -------------------------------------------------------------------------------- 1 | img { 2 | filter: sepia(100%) saturate(2000%) brightness(70%) hue-rotate(90deg); 3 | } 4 | -------------------------------------------------------------------------------- /docs/src/assets/allow-user-scripts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luciopaiva/witchcraft/HEAD/docs/src/assets/allow-user-scripts.png -------------------------------------------------------------------------------- /docs/src/assets/manage-extension.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luciopaiva/witchcraft/HEAD/docs/src/assets/manage-extension.png -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "astro/tsconfigs/strict", 3 | "include": [".astro/types.d.ts", "**/*"], 4 | "exclude": ["dist"] 5 | } 6 | -------------------------------------------------------------------------------- /chrome-extension/constants.js: -------------------------------------------------------------------------------- 1 | 2 | export const DEFAULT_SERVER_ADDRESS = "http://127.0.0.1:5743"; 3 | export const SERVER_PING_PERIOD_IN_MILLIS = 5000; 4 | -------------------------------------------------------------------------------- /docs/src/assets/ChromeWebStore_Badge_v2_340x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luciopaiva/witchcraft/HEAD/docs/src/assets/ChromeWebStore_Badge_v2_340x96.png -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | coverage 4 | .git 5 | .gitignore 6 | README.md 7 | .env 8 | .nyc_output 9 | *.log 10 | .DS_Store 11 | 12 | -------------------------------------------------------------------------------- /test/integration/resources/test.js: -------------------------------------------------------------------------------- 1 | 2 | console.info("running"); 3 | const h1 = document.querySelector('h1'); 4 | if (h1) { 5 | h1.innerText = "Goodbye World"; 6 | } 7 | -------------------------------------------------------------------------------- /docs/src/assets/google.com.js: -------------------------------------------------------------------------------- 1 | window.addEventListener("load", () => { 2 | document.querySelectorAll("img") 3 | .forEach(img => img.style.transform = "scaleX(-1)"); 4 | }); 5 | -------------------------------------------------------------------------------- /docs/src/assets/include.js: -------------------------------------------------------------------------------- 1 | // @include my-script.js 2 | // @include "some other script.js" 3 | /* @include third-script.js */ 4 | 5 | setup(); // `setup` is defined in `my-script.js` 6 | -------------------------------------------------------------------------------- /docs/src/content/docs/cookbook/waiting-for-document-state.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Waiting for a specific document state 3 | --- 4 | 5 | import { Code } from '@astrojs/starlight/components'; 6 | 7 | -------------------------------------------------------------------------------- /art/README.md: -------------------------------------------------------------------------------- 1 | 2 | All art by Lucio Paiva done using GIMP, except `witch.svg` and `witch-hat.svg` which were kindly shared by [Freepik](https://www.flaticon.com/authors/freepik), downloaded from [Flaticon](https://www.flaticon.com). 3 | -------------------------------------------------------------------------------- /docs/src/content.config.ts: -------------------------------------------------------------------------------- 1 | import { defineCollection } from 'astro:content'; 2 | import { docsLoader } from '@astrojs/starlight/loaders'; 3 | import { docsSchema } from '@astrojs/starlight/schema'; 4 | 5 | export const collections = { 6 | docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }), 7 | }; 8 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | # build output 2 | dist/ 3 | # generated types 4 | .astro/ 5 | 6 | # dependencies 7 | node_modules/ 8 | 9 | # logs 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | pnpm-debug.log* 14 | 15 | 16 | # environment variables 17 | .env 18 | .env.production 19 | 20 | # macOS-specific files 21 | .DS_Store 22 | -------------------------------------------------------------------------------- /chrome-extension/analytics/index.js: -------------------------------------------------------------------------------- 1 | import Metrics from "./metrics.js"; 2 | import agent from "./agent.js"; 3 | import events from "./events.js"; 4 | 5 | function page(path, title) { 6 | analytics.agent.firePageViewEvent(path, title).then(); 7 | } 8 | 9 | export const analytics = { 10 | Metrics, 11 | agent, 12 | events, 13 | page, 14 | }; 15 | -------------------------------------------------------------------------------- /.github/copilot-instructions.md: -------------------------------------------------------------------------------- 1 | When running commands in the terminal, keep in mind that: 2 | 3 | - the terminal always opens in the root folder of the project, so there's no need to `cd` to it before running commands 4 | - the project is using `nvm` to manage Node versions, so run `nvm install` before running the first command that requires either `node` or one of its package managers 5 | -------------------------------------------------------------------------------- /chrome-extension/script/include-context.js: -------------------------------------------------------------------------------- 1 | 2 | export default class IncludeContext { 3 | /** @type {ScriptContext} */ 4 | script; 5 | startIndex = -1; 6 | endIndex = -1; 7 | 8 | constructor(script, startIndex, endIndex) { 9 | this.script = script; 10 | this.startIndex = startIndex; 11 | this.endIndex = endIndex; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /docs/src/styles/global.css: -------------------------------------------------------------------------------- 1 | /* Global styles for Witchcraft documentation */ 2 | 3 | /* margin variables */ 4 | :root { 5 | --vertical-margin: 2rem; 6 | } 7 | 8 | .sl-markdown-content img { 9 | width: 80%; 10 | display: block; 11 | margin: var(--vertical-margin) auto; 12 | } 13 | 14 | .expressive-code { 15 | margin: var(--vertical-margin) auto; 16 | } 17 | -------------------------------------------------------------------------------- /chrome-extension/util/debouncer.js: -------------------------------------------------------------------------------- 1 | 2 | export default class Debouncer { 3 | 4 | constructor(waitInMillis) { 5 | this.waitInMillis = waitInMillis; 6 | this.timeout = undefined; 7 | } 8 | 9 | debounce(action) { 10 | clearTimeout(this.timeout); 11 | this.timeout = setTimeout(() => { 12 | action(); 13 | }, this.waitInMillis); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /docs/src/content/docs/cookbook/password.css: -------------------------------------------------------------------------------- 1 | 2 | .password-container { 3 | display: flex; 4 | justify-content: center; 5 | width: 100%; 6 | } 7 | 8 | .password-field { 9 | width: 1.4em; 10 | text-align: center; 11 | margin: 0 .6em; 12 | } 13 | 14 | article.card { 15 | margin-top: 2rem; 16 | padding: 2rem; 17 | p.title { 18 | display: none; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docs", 3 | "type": "module", 4 | "version": "0.0.1", 5 | "scripts": { 6 | "dev": "astro dev", 7 | "start": "astro dev", 8 | "build": "astro build", 9 | "preview": "astro preview", 10 | "astro": "astro" 11 | }, 12 | "dependencies": { 13 | "@astrojs/starlight": "^0.35.3", 14 | "astro": "^5.14.4", 15 | "sharp": "^0.34.2", 16 | "starlight-theme-rapide": "^0.5.1" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /docs/src/content/docs/new-in-v3.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: New in v3 3 | --- 4 | 5 | Witchcraft has been completely revamped to work with [MV3](https://developer.chrome.com/docs/extensions/develop/migrate/what-is-mv3). All outstanding bugs have been fixed and old users should be able to use it just fine now. 6 | 7 | For those that were using the old Web Server Chrome app, be aware that it now only works on ChromeOS. Please see the [installation page](/how-to-install) for alternatives. 8 | -------------------------------------------------------------------------------- /docs/src/content/docs/credits.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Credits 3 | --- 4 | 5 | Witchcraft is my rendition of [defunkt](https://github.com/defunkt)'s original extension, [dotjs](https://github.com/defunkt/dotjs). Although I never got to actually use dotjs (it only worked on macOS and the installation process was not easy), I really wanted something like that. defunkt now kindly [mentions](https://github.com/defunkt/dotjs/blob/master/README.markdown) Witchcraft as the go-to alternative to dotjs. 6 | 7 | Images in the logo were provided by [Freepik](https://www.flaticon.com/authors/freepik). 8 | -------------------------------------------------------------------------------- /test/path/map-to-js-and-css.js: -------------------------------------------------------------------------------- 1 | 2 | import assert from "assert"; 3 | import { describe, it } from "mocha"; 4 | import path from "../../chrome-extension/path.js"; 5 | import sinon from "sinon"; 6 | 7 | const { mapToJsAndCss } = path; 8 | 9 | describe("Map to JS and CSS", function () { 10 | 11 | beforeEach(function () { 12 | sinon.restore(); 13 | }); 14 | 15 | it("simple path", function () { 16 | const result = mapToJsAndCss("foo.bar/fizz/buzz.html"); 17 | assert.deepStrictEqual(result, [ 18 | [ "foo.bar/fizz/buzz.html", "js" ], 19 | [ "foo.bar/fizz/buzz.html", "css" ] 20 | ]); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /test/integration/utils/resource-utils.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import { fileURLToPath } from 'url'; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = path.dirname(__filename); 7 | 8 | export async function loadResource(resourcePath) { 9 | // load a file using fs from the test/integration/resources directory 10 | const fullPath = path.join(__dirname, '..', 'resources', resourcePath); 11 | try { 12 | const content = await fs.promises.readFile(fullPath, 'utf8'); 13 | return content; 14 | } catch (error) { 15 | throw new Error(`Failed to load resource '${resourcePath}': ${error.message}`); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /chrome-extension/script/script-context.js: -------------------------------------------------------------------------------- 1 | 2 | export default class ScriptContext { 3 | /** @type {string} */ 4 | path; 5 | /** @type {string} */ 6 | type; 7 | /** @type {string} */ 8 | url; 9 | /** @type {string} */ 10 | #contents; 11 | #hasContents = false; 12 | 13 | constructor(path, type) { 14 | this.path = path; 15 | this.type = type; 16 | } 17 | 18 | get contents() { 19 | return this.#contents; 20 | } 21 | 22 | set contents(contents) { 23 | this.#contents = contents; 24 | this.#hasContents = typeof contents === "string"; 25 | } 26 | 27 | get hasContents() { 28 | return this.#hasContents; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /test/path/path-tuple-to-script-context.js: -------------------------------------------------------------------------------- 1 | 2 | import assert from "assert"; 3 | import { describe, it } from "mocha"; 4 | import path from "../../chrome-extension/path.js"; 5 | import ScriptContext from "../../chrome-extension/script/script-context.js"; 6 | import sinon from "sinon"; 7 | 8 | const { pathTupleToScriptContext } = path; 9 | 10 | describe("Path tuple to ScriptContext", function () { 11 | 12 | beforeEach(function () { 13 | sinon.restore(); 14 | }); 15 | 16 | it ("simple tuple", function () { 17 | const result = pathTupleToScriptContext(["foo.bar/fizz/buzz.html", "js"]); 18 | assert.deepStrictEqual(result, new ScriptContext("foo.bar/fizz/buzz.html.js", "js")); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /chrome-extension/badge.js: -------------------------------------------------------------------------------- 1 | 2 | import {storage} from "./storage/index.js"; 3 | import {browser} from "./browser.js"; 4 | 5 | async function clear(tabId) { 6 | await storage.removeTabScriptSet(tabId); 7 | await badge.setCount(tabId, 0); 8 | } 9 | 10 | async function registerScripts(tabId, frameId, scripts) { 11 | const totalCount = await storage.addToTabScriptSet(tabId, frameId, scripts); 12 | await badge.setCount(tabId, totalCount); 13 | } 14 | 15 | async function setCount(tabId, count) { 16 | const countStr = count > 999 ? "999+" : (count > 0 ? count.toString() : ""); 17 | await browser.setBadgeText(tabId, countStr); 18 | } 19 | 20 | export const badge = { 21 | clear, 22 | registerScripts, 23 | setCount, 24 | }; 25 | -------------------------------------------------------------------------------- /chrome-extension/util/embed-script.js: -------------------------------------------------------------------------------- 1 | 2 | // exported solely for testing 3 | export function injector(document) { 4 | const fnStr = (function fn() { /*INJECTION_POINT*/ }).toString(); 5 | const script = document.createElement("script"); 6 | script.text = `(${fnStr})()`; 7 | // when injecting at document_start, experimentation shows that
doesn't exist and may not exist either 8 | // this is why here we are injecting the script tag directly into , which seems guaranteed to exist 9 | document.documentElement.appendChild(script); 10 | } 11 | 12 | const [INJECTOR_PREFIX, INJECTOR_SUFFIX] = injector.toString().split("/*INJECTION_POINT*/"); 13 | 14 | export function embedScript(contents) { 15 | return `(${INJECTOR_PREFIX}${contents}${INJECTOR_SUFFIX})(document)`; 16 | } 17 | -------------------------------------------------------------------------------- /chrome-extension/url.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * @param {string} origin 4 | * @param {string} path 5 | */ 6 | export function composeUrl(origin, path) { 7 | const url = new URL(origin); 8 | const newUrl = new URL(path, origin); 9 | return `${newUrl.href}${url.search}${url.hash}`; 10 | } 11 | 12 | export function tryParseUrl(url) { 13 | try { 14 | const location = new URL(url); 15 | return { hostName: location.hostname, pathName: location.pathname }; 16 | } catch (_) { 17 | return { hostName: "", pathName: "" }; 18 | } 19 | } 20 | 21 | /* 22 | * Functions are exported like this instead of directly exposed so that they can be unit-tested. 23 | * See https://javascript.plainenglish.io/unit-testing-challenges-with-modular-javascript-patterns-22cc22397362 24 | */ 25 | export const url = { 26 | composeUrl, 27 | tryParseUrl, 28 | }; 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |  3 | 4 | Witchcraft is a Google Chrome extension for loading custom JavaScript and CSS directly from a folder on your local machine, injecting them into web pages that match specified URL patterns. 5 | 6 | It works by matching every page domain against script file names available in the scripts folder. For instance, if one navigates to `google.com`, Witchcraft will try to load and run `google.com.js` and `google.com.css`. 7 | 8 | For more information on how to install and use it, head to Witchcraft's [home page](//luciopaiva.com/witchcraft). 9 | 10 | # Development 11 | 12 | See [here](./development.md). 13 | 14 | # Credits 15 | 16 | Witchcraft is my rendition of [defunkt](//github.com/defunkt)'s original extension, [dotjs](//github.com/defunkt/dotjs). 17 | 18 | Images in the logo were provided by [Freepik](//www.flaticon.com/authors/freepik). 19 | -------------------------------------------------------------------------------- /test/script/expand-include.js: -------------------------------------------------------------------------------- 1 | 2 | import assert from "assert"; 3 | import { describe, it } from "mocha"; 4 | import sinon from "sinon"; 5 | import ScriptContext from "../../chrome-extension/script/script-context.js"; 6 | import IncludeContext from "../../chrome-extension/script/include-context.js"; 7 | import {util} from "../../chrome-extension/util/index.js"; 8 | import {script} from "../../chrome-extension/script/index.js"; 9 | 10 | describe("Expand include", function () { 11 | 12 | beforeEach(function () { 13 | sinon.restore(); 14 | }); 15 | 16 | it("simple expansion", function () { 17 | sinon.stub(util, "spliceString").returns("foo"); 18 | const baseScript = new ScriptContext(); 19 | const includeScript = new ScriptContext(); 20 | const include = new IncludeContext(includeScript); 21 | script.expandInclude(baseScript, include); 22 | 23 | assert.strictEqual(baseScript.contents, "foo"); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:24.7.0-slim AS app 2 | 3 | # We don't need the standalone Chromium 4 | ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true 5 | 6 | # Install Google Chrome Stable and fonts 7 | # Note: this installs the necessary libs to make the browser work with Puppeteer. 8 | RUN apt-get update && apt-get install curl gnupg -y \ 9 | && curl --location --silent https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \ 10 | && sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' \ 11 | && apt-get update \ 12 | && apt-get install google-chrome-stable -y --no-install-recommends \ 13 | && rm -rf /var/lib/apt/lists/* 14 | 15 | # Create app directory 16 | WORKDIR /app 17 | 18 | # Copy package files 19 | COPY package*.json ./ 20 | 21 | # Install dependencies 22 | RUN npm ci --only=production=false 23 | 24 | # Copy source code 25 | COPY . . 26 | 27 | # Default command to run integration tests 28 | CMD ["npm", "run", "test:integration"] 29 | -------------------------------------------------------------------------------- /chrome-extension/background.js: -------------------------------------------------------------------------------- 1 | import {loader} from "./loader.js"; 2 | import {storage} from "./storage/index.js"; 3 | import {browser} from "./browser.js"; 4 | import {icon} from "./icon.js"; 5 | import {analytics} from "./analytics/index.js"; 6 | 7 | analytics.events.backgroundLoaded(); 8 | browser.onInstalled(async () => { 9 | console.info("Extension installed."); 10 | analytics.events.installed(); 11 | }); 12 | browser.onSuspend(async () => { 13 | console.info("Extension suspended"); 14 | analytics.events.suspended(); 15 | }); 16 | 17 | browser.onMessage(async (message, sender) => { 18 | const url = message.href; 19 | const tabId = sender.tab?.id; 20 | const frameId = sender.frameId; 21 | 22 | if (typeof url === "string" && typeof tabId === "number" && typeof frameId === "number") { 23 | const metrics = await loader.loadScripts(url, tabId, frameId); 24 | metrics.hasData && analytics.events.scriptsLoaded(metrics); 25 | await storage.evictStale(); 26 | } 27 | }); 28 | 29 | icon.initialize().then(); 30 | -------------------------------------------------------------------------------- /test/util/base-64-to-typed-array.js: -------------------------------------------------------------------------------- 1 | import assert from "assert"; 2 | import { describe, it } from "mocha"; 3 | import {util} from "../../chrome-extension/util/index.js"; 4 | import sinon from "sinon"; 5 | 6 | describe("Base 64 / Typed Array conversion", function () { 7 | 8 | beforeEach(function () { 9 | sinon.restore(); 10 | }); 11 | 12 | it("typed array to base 64", async function () { 13 | const data = new Uint8ClampedArray([1, 2, 3, 255]); 14 | assert.strictEqual(util.typedArrayToBase64(data), "AQID/w=="); 15 | }); 16 | 17 | it("base 64 to Uint8ClampedArray", async function () { 18 | const base64 = "AQID/w=="; 19 | const data = util.base64ToTypedArray(base64, Uint8ClampedArray); 20 | assert.strictEqual(data.toString(), "1,2,3,255"); 21 | }); 22 | 23 | it("base 64 to Int8Array", async function () { 24 | const base64 = "AQID/w=="; 25 | const data = util.base64ToTypedArray(base64, Int8Array); 26 | assert.strictEqual(data.toString(), "1,2,3,-1"); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | # 3.3.0 3 | 4 | - use content scripts to inject scripts at `document_start` 5 | 6 | # 3.2.0 7 | 8 | - prefer `userScripts` over `scripting` (#71) 9 | 10 | # 3.1.0 11 | 12 | - try path combinations with all domain levels + `_global` 13 | 14 | # 3.0.0 15 | 16 | - migrate to Manifest V3 17 | - fix all outstanding bugs 18 | - major refactor 19 | - CI pipeline with unit + integration tests 20 | 21 | # 2.6.1 22 | 23 | - fix #25 24 | 25 | # 2.6.0 26 | 27 | - ability to include remote scripts 28 | - ability to change server address 29 | - analytics 30 | - several minor fixes and improvements 31 | 32 | # 2.5.0 33 | 34 | - handle path segments 35 | 36 | # 2.3.0 37 | 38 | - `_global.js/css` support 39 | - unit tests 40 | 41 | # 2.2.0 42 | 43 | - popup improvements 44 | 45 | # 2.1.0 46 | 47 | - load scripts from local web server 48 | - popup improvements 49 | 50 | # 2.0.1 51 | 52 | - `@include` directive 53 | 54 | # 1.0.3 55 | 56 | - fix #1 57 | 58 | # 1.0.2 59 | 60 | - add badge with number of scripts loaded 61 | 62 | # 1.0.0 63 | 64 | - first working version 65 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2025 Lucio Paiva 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. -------------------------------------------------------------------------------- /chrome-extension/analytics/events.js: -------------------------------------------------------------------------------- 1 | 2 | import {analytics} from "./index.js"; 3 | 4 | function installed() { 5 | analytics.agent.fireEvent("installed").then(); 6 | } 7 | 8 | function backgroundLoaded() { 9 | analytics.agent.fireEvent("background_loaded").then(); 10 | } 11 | 12 | /** 13 | * @param {Metrics} metrics 14 | */ 15 | function scriptsLoaded(metrics) { 16 | const params = { 17 | js_hit_count: metrics.jsHitCount, 18 | js_include_hit_count: metrics.jsIncludesHitCount, 19 | js_include_miss_count: metrics.jsIncludesNotFoundCount, 20 | css_hit_count: metrics.cssHitCount, 21 | css_include_hit_count: metrics.cssIncludesHitCount, 22 | css_include_miss_count: metrics.cssIncludesNotFoundCount, 23 | server_error_count: metrics.errorCount, 24 | fetch_fail_count: metrics.failCount, 25 | }; 26 | analytics.agent.fireEvent("scripts_loaded", params).then(); 27 | } 28 | 29 | function suspended() { 30 | analytics.agent.fireEvent("suspended").then(); 31 | } 32 | 33 | const event = { 34 | installed, 35 | backgroundLoaded, 36 | scriptsLoaded, 37 | suspended, 38 | }; 39 | 40 | export default event; 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "witchcraft", 3 | "version": "3.2.0", 4 | "description": "", 5 | "main": "index.js", 6 | "directories": { 7 | "doc": "docs" 8 | }, 9 | "scripts": { 10 | "build": "node build.js", 11 | "test:unit": "mocha 'test/**/*.js' --ignore 'test/integration/**' --recursive", 12 | "test:coverage": "c8 -r lcov -r text mocha 'test/**/*.js' --ignore 'test/integration/**' --recursive", 13 | "test:integration": "npm run build && mocha test/integration/integration.js --timeout 30000", 14 | "test": "npm run test:unit && npm run test:integration" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/luciopaiva/witchcraft.git" 19 | }, 20 | "type": "module", 21 | "keywords": [], 22 | "author": "", 23 | "license": "MIT", 24 | "bugs": { 25 | "url": "https://github.com/luciopaiva/witchcraft/issues" 26 | }, 27 | "homepage": "https://github.com/luciopaiva/witchcraft#readme", 28 | "devDependencies": { 29 | "c8": "^10.1.3", 30 | "mocha": "^11.7.2", 31 | "nyc": "^17.1.0", 32 | "puppeteer": "^24.17.1", 33 | "sinon": "^15.1.0", 34 | "sinon-chrome": "^3.0.1" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /test/url/try-parse-url.js: -------------------------------------------------------------------------------- 1 | 2 | import assert from "assert"; 3 | import { describe, it } from "mocha"; 4 | import {tryParseUrl} from "../../chrome-extension/url.js"; 5 | import sinon from "sinon"; 6 | 7 | describe("Parse URL", function () { 8 | 9 | beforeEach(function () { 10 | sinon.restore(); 11 | }); 12 | 13 | it ("Only host name", function () { 14 | const result = tryParseUrl("https://www.google.com"); 15 | assert.deepStrictEqual(result, makeResponse("www.google.com", "/")); 16 | }); 17 | 18 | it ("Host name and path", function () { 19 | const result = tryParseUrl("https://luciopaiva.com/foo/bar/"); 20 | assert.deepStrictEqual(result, makeResponse("luciopaiva.com", "/foo/bar/")); 21 | }); 22 | 23 | it ("Empty host", function () { 24 | const result = tryParseUrl("https://"); 25 | assert.deepStrictEqual(result, makeResponse("", "")); 26 | }); 27 | 28 | it ("No protocol", function () { 29 | const result = tryParseUrl("foo.com/bar"); 30 | assert.deepStrictEqual(result, makeResponse("", "")); 31 | }); 32 | }); 33 | 34 | function makeResponse(hostName, pathName) { 35 | return { hostName, pathName }; 36 | } 37 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ master, main ] 6 | pull_request: 7 | branches: [ master, main ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v4 16 | 17 | - name: Read Node.js version from .nvmrc 18 | id: nvmrc 19 | run: echo "node-version=$(cat .nvmrc)" >> $GITHUB_OUTPUT 20 | 21 | - name: Setup Node.js 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: ${{ steps.nvmrc.outputs.node-version }} 25 | cache: 'npm' 26 | 27 | - name: Install dependencies 28 | run: npm ci 29 | 30 | - name: Run unit tests 31 | run: npm run test:unit 32 | 33 | - name: Run integration tests 34 | run: npm run test:integration 35 | 36 | - name: Upload coverage reports 37 | uses: codecov/codecov-action@v4 38 | if: success() 39 | with: 40 | file: ./coverage/lcov.info 41 | flags: unittests 42 | name: codecov-umbrella 43 | fail_ci_if_error: false 44 | env: 45 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 46 | -------------------------------------------------------------------------------- /chrome-extension/util/fetch-script.js: -------------------------------------------------------------------------------- 1 | 2 | const FETCH_OPTIONS = { 3 | cache: "no-store", 4 | }; 5 | 6 | export const FETCH_RESPONSE_OUTCOME = { 7 | SUCCESS: 0, 8 | NOT_FOUND: 1, 9 | SERVER_FAILURE: 2, 10 | FETCH_FAILURE: 3, 11 | }; 12 | 13 | class FetchResponse { 14 | /** @type {string} */ 15 | contents; 16 | /** @type {number} */ 17 | outcome; 18 | } 19 | 20 | /** 21 | * @param {string} url 22 | * @param {Function} fetchFn 23 | * @returns {Promise
13 |
14 | Make it executable:
15 |
16 | ```bash
17 | chmod +x witchcraft-server.sh
18 | ```
19 |
20 | And you can now test it to bring the server up and then down:
21 |
22 | ```bash
23 | ./witchcraft-server.sh start
24 | ./witchcraft-server.sh stop
25 | ```
26 |
27 | With that working, you can now create a task in Windows Task Scheduler to run this script at startup:
28 |
29 | 1. Open Task Scheduler (you can find it by searching in the Start menu).
30 | 2. Click on "Create Task..." (not "basic") in the right-hand pane.
31 | 3. In the "General" tab:
32 | - Name your task (e.g., "Witchcraft Server")
33 | - Configure for "Windows 10/11" or your version of Windows
34 | 4. In the "Triggers" tab:
35 | - Click "New..."
36 | - Set "Begin the task" to "At log on"
37 | - Uncheck "Stop task if it runs longer than..."
38 | 5. In the "Actions" tab:
39 | - Click "New..."
40 | - Set "Action" to "Start a program"
41 | - In the "Program/script" field, enter `C:\Windows\System32\wsl.exe`
42 | - In the "Add arguments (optional)" field, enter this: `-d Ubuntu -- bash -lc '~/witchcraft-server.sh start'` (but double-check the script path to make sure it matches where you saved it in WSL2)
43 |
44 | And that's it. You can now manually run the task to test it, or simply restart your computer and check if the Witchcraft server is running by looking at the extension's status indicator.
45 |
--------------------------------------------------------------------------------
/chrome-extension/storage/evict-stale.js:
--------------------------------------------------------------------------------
1 | import {browser} from "../browser.js";
2 | import {storage} from "./index.js";
3 |
4 | const EVICTION_PERIOD_IN_MILLIS = 1000 * 60 * 10; // 10 min
5 |
6 | export async function evictStale() {
7 | const now = Date.now();
8 | if (await isItTimeToEvict(now)) {
9 | await browser.storeKey(storage.EVICTION_TIME_KEY, now + EVICTION_PERIOD_IN_MILLIS);
10 | await lookForKeysToEvict();
11 | }
12 | }
13 |
14 | async function lookForKeysToEvict() {
15 | console.info("Looking for old cache entries to evict...");
16 |
17 | let removedCount = 0;
18 | for (const entry of await browser.retrieveAllEntries()) {
19 | const [key, value] = entry;
20 | if (key.startsWith(storage.FRAME_SCRIPTS_KEY_PREFIX)) {
21 | const {tabId, frameId} = value;
22 | removedCount += (await tryEvictFrameKey(key, tabId, frameId)) ? 1 : 0;
23 | } else if (key.startsWith(storage.TAB_SCRIPT_COUNT_KEY_PREFIX)) {
24 | const {tabId} = value;
25 | removedCount += (await tryEvictTabScriptCountKey(key, tabId)) ? 1 : 0;
26 | }
27 | }
28 |
29 | console.info(`Entries removed: ${removedCount}`);
30 | }
31 |
32 | async function tryEvictFrameKey(key, tabId, frameId) {
33 | if (!(await doesFrameExist(tabId, frameId))) {
34 | // console.info(`Removing key ${key}...`);
35 | await storage.removeFrame(tabId, frameId);
36 | return true;
37 | }
38 | return false;
39 | }
40 |
41 | async function tryEvictTabScriptCountKey(key, tabId) {
42 | if (!(await doesFrameExist(tabId, 0))) {
43 | // console.info(`Removing key ${key}...`);
44 | await storage.removeTabScriptSet(tabId);
45 | return true;
46 | }
47 | return false;
48 | }
49 |
50 | async function doesFrameExist(tabId, frameId) {
51 | return !!(await browser.getFrame(tabId, frameId));
52 | }
53 |
54 | async function isItTimeToEvict(now) {
55 | const nextTime = await browser.retrieveKey(storage.EVICTION_TIME_KEY);
56 | return (nextTime ?? 0) < now;
57 | }
58 |
--------------------------------------------------------------------------------
/test/browser/chrome-api/get-tab-url.js:
--------------------------------------------------------------------------------
1 | import assert from "node:assert";
2 | import {describe, it} from "mocha";
3 | import sinon from "sinon";
4 | import {browser} from "../../../chrome-extension/browser.js";
5 |
6 | describe("Chrome API - Get tab URL", function () {
7 |
8 | beforeEach(function () {
9 | sinon.restore();
10 | });
11 |
12 | it("successful get", async function () {
13 | const url = "https://foo.com";
14 |
15 | const chrome = {
16 | tabs: {
17 | get: sinon.fake((tabId, callback) => {
18 | callback({url});
19 | }),
20 | }
21 | };
22 | sinon.replace(browser, "chrome", () => chrome);
23 |
24 | const tabId = 10;
25 | assert.strictEqual(await browser.getTabUrl(tabId), url);
26 | assert(chrome.tabs.get.calledWith(tabId));
27 | });
28 |
29 | it("resolved with runtime error", async function () {
30 | const url = "https://foo.com";
31 |
32 | const chrome = {
33 | tabs: {
34 | get: sinon.fake((tabId, callback) => {
35 | callback({url});
36 | }),
37 | },
38 | runtime: {
39 | lastError: {
40 | message: "Something went wrong",
41 | }
42 | }
43 | };
44 | sinon.replace(browser, "chrome", () => chrome);
45 |
46 | const tabId = 10;
47 | await assert.rejects(async () => {
48 | await browser.getTabUrl(tabId);
49 | });
50 | assert(chrome.tabs.get.calledWith(tabId));
51 | });
52 |
53 | it("failed get", async function () {
54 | const chrome = {
55 | tabs: {
56 | get: sinon.fake.throws(),
57 | }
58 | };
59 | sinon.replace(browser, "chrome", () => chrome);
60 |
61 | const tabId = 10;
62 | await assert.rejects(async () => {
63 | await browser.getTabUrl(tabId);
64 | });
65 | assert(chrome.tabs.get.calledWith(tabId));
66 | });
67 | });
68 |
--------------------------------------------------------------------------------
/test/loader/load-includes.js:
--------------------------------------------------------------------------------
1 |
2 | import { describe, it } from "mocha";
3 | import sinon from "sinon";
4 | import {loader} from "../../chrome-extension/loader.js";
5 | import {script} from "../../chrome-extension/script/index.js";
6 | import {analytics} from "../../chrome-extension/analytics/index.js";
7 | import {EXT_JS} from "../../chrome-extension/path.js";
8 |
9 | const {IncludeContext, ScriptContext} = script;
10 | const {Metrics} = analytics;
11 |
12 | describe("Load includes", function () {
13 |
14 | beforeEach(function () {
15 | sinon.restore();
16 | });
17 |
18 | it("no includes", function () {
19 | const findIncludeDirective = sinon.stub(script, "findIncludeDirective");
20 | const processIncludeDirective = sinon.stub(script, "processIncludeDirective");
21 | const expandInclude = sinon.stub(script, "expandInclude");
22 |
23 | const baseScript = new ScriptContext();
24 | const metrics = new Metrics();
25 | const visitedUrls = new Set();
26 |
27 | loader.loadIncludes(baseScript, metrics, visitedUrls);
28 |
29 | sinon.assert.calledOnce(findIncludeDirective);
30 | sinon.assert.notCalled(processIncludeDirective);
31 | sinon.assert.notCalled(expandInclude);
32 | });
33 |
34 | it("one include", async function () {
35 | const findIncludeDirective = sinon.stub(script, "findIncludeDirective");
36 |
37 | const includeScript = new ScriptContext("/foo/bar", EXT_JS);
38 | const include = new IncludeContext(includeScript, 2, 7);
39 | findIncludeDirective.onFirstCall().returns(include);
40 |
41 | const processIncludeDirective = sinon.stub(script, "processIncludeDirective");
42 | const expandInclude = sinon.stub(script, "expandInclude");
43 |
44 | const baseScript = new ScriptContext();
45 | const metrics = new Metrics();
46 | const visitedUrls = new Set();
47 |
48 | await loader.loadIncludes(baseScript, metrics, visitedUrls);
49 |
50 | sinon.assert.calledTwice(findIncludeDirective);
51 | sinon.assert.calledOnce(processIncludeDirective);
52 | sinon.assert.calledOnce(expandInclude);
53 | });
54 | });
55 |
--------------------------------------------------------------------------------
/test/browser/chrome-api/capture-runtime-error.js:
--------------------------------------------------------------------------------
1 | import assert from "node:assert";
2 | import {describe, it} from "mocha";
3 | import sinon from "sinon";
4 | import {browser} from "../../../chrome-extension/browser.js";
5 |
6 | describe("Chrome API - Capture runtime error", function () {
7 |
8 | beforeEach(function () {
9 | sinon.restore();
10 | });
11 |
12 | it("no errors", function () {
13 | const chrome = {
14 | runtime: {}
15 | };
16 | sinon.replace(browser, "chrome", () => chrome);
17 |
18 | const logger = {
19 | error: sinon.stub(),
20 | };
21 |
22 | assert.strictEqual(browser.captureRuntimeError(logger), false);
23 | assert(logger.error.notCalled);
24 | });
25 |
26 | it("unknown error", function () {
27 | const chrome = {
28 | runtime: {
29 | lastError: {
30 | message: "Some unexpected error happened.",
31 | }
32 | }
33 | };
34 | sinon.replace(browser, "chrome", () => chrome);
35 |
36 | const logger = {
37 | error: sinon.stub(),
38 | };
39 |
40 | assert.strictEqual(browser.captureRuntimeError(logger), true);
41 | assert(logger.error.calledOnce);
42 | });
43 |
44 | [
45 | {
46 | name: "no tab with id",
47 | message: "No tab with id: 1064422151.",
48 | },
49 | {
50 | name: "no frame with id",
51 | message: "No frame with id 3216546 in tab 1064422151.",
52 | },
53 | ].forEach(({ name, message }) => {
54 | it(`ignored error: ${name}`, function () {
55 | const chrome = {
56 | runtime: {
57 | lastError: {
58 | message,
59 | },
60 | }
61 | };
62 | sinon.replace(browser, "chrome", () => chrome);
63 |
64 | const logger = {
65 | error: sinon.stub(),
66 | };
67 |
68 | assert.strictEqual(browser.captureRuntimeError(logger), true);
69 | assert(logger.error.notCalled);
70 | });
71 | })
72 | });
73 |
--------------------------------------------------------------------------------
/chrome-extension/analytics/metrics.js:
--------------------------------------------------------------------------------
1 |
2 | import {EXT_CSS, EXT_JS} from "../path.js";
3 |
4 | export default class Metrics {
5 | #jsHitCount = 0;
6 | #cssHitCount = 0;
7 | #errorCount = 0;
8 | #failCount = 0;
9 | #jsIncludesHitCount = 0;
10 | #cssIncludesHitCount = 0;
11 | #jsIncludesNotFoundCount = 0;
12 | #cssIncludesNotFoundCount = 0;
13 | #hasData = false;
14 |
15 | incrementHitCount(type) {
16 | if (type === EXT_JS) {
17 | this.#jsHitCount++;
18 | } else if (type === EXT_CSS) {
19 | this.#cssHitCount++;
20 | }
21 | this.#hasData = true;
22 | }
23 |
24 | incrementErrorCount() {
25 | this.#errorCount++;
26 | this.#hasData = true;
27 | }
28 |
29 | incrementFailCount() {
30 | this.#failCount++;
31 | this.#hasData = true;
32 | }
33 |
34 | incrementIncludesHit(type) {
35 | if (type === EXT_JS) {
36 | this.#jsIncludesHitCount++;
37 | } else if (type === EXT_CSS) {
38 | this.#cssIncludesHitCount++;
39 | }
40 | this.#hasData = true;
41 | }
42 |
43 | incrementIncludesNotFound(type) {
44 | if (type === EXT_JS) {
45 | this.#jsIncludesNotFoundCount++;
46 | } else if (type === EXT_CSS) {
47 | this.#cssIncludesNotFoundCount++;
48 | }
49 | this.#hasData = true;
50 | }
51 |
52 | get jsHitCount() {
53 | return this.#jsHitCount;
54 | }
55 |
56 | get cssHitCount() {
57 | return this.#cssHitCount;
58 | }
59 |
60 | get errorCount() {
61 | return this.#errorCount;
62 | }
63 |
64 | get failCount() {
65 | return this.#failCount;
66 | }
67 |
68 | get jsIncludesHitCount() {
69 | return this.#jsIncludesHitCount;
70 | }
71 |
72 | get cssIncludesHitCount() {
73 | return this.#cssIncludesHitCount;
74 | }
75 |
76 | get jsIncludesNotFoundCount() {
77 | return this.#jsIncludesNotFoundCount;
78 | }
79 |
80 | get cssIncludesNotFoundCount() {
81 | return this.#cssIncludesNotFoundCount;
82 | }
83 |
84 | get hasData() {
85 | return this.#hasData;
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/.github/workflows/deploy-docs.yml:
--------------------------------------------------------------------------------
1 | name: Build docs
2 |
3 | on:
4 | # Runs on pushes targeting the default branch
5 | push:
6 | branches: ["main"]
7 | paths: ["docs/**"]
8 |
9 | # Runs on PRs to validate docs build
10 | pull_request:
11 | branches: ["main"]
12 | paths: ["docs/**"]
13 |
14 | # Allows you to run this workflow manually from the Actions tab
15 | workflow_dispatch:
16 |
17 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
18 | permissions:
19 | contents: read
20 | pages: write
21 | id-token: write
22 |
23 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
24 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
25 | concurrency:
26 | group: "pages"
27 | cancel-in-progress: false
28 |
29 | jobs:
30 | build:
31 | runs-on: ubuntu-latest
32 | steps:
33 | - name: Checkout
34 | uses: actions/checkout@v4
35 |
36 | - name: Setup Node.js
37 | uses: actions/setup-node@v4
38 | with:
39 | node-version-file: '.nvmrc'
40 | cache: 'npm'
41 | cache-dependency-path: 'docs/package-lock.json'
42 |
43 | - name: Setup Pages
44 | id: pages
45 | uses: actions/configure-pages@v5
46 | if: github.event_name == 'push'
47 |
48 | - name: Install dependencies
49 | run: npm ci
50 | working-directory: ./docs
51 |
52 | - name: Build with Astro
53 | run: npm run build
54 | working-directory: ./docs
55 | env:
56 | # Tell Astro where the site will be deployed
57 | ASTRO_SITE: ${{ steps.pages.outputs.origin }}
58 | ASTRO_BASE: ${{ steps.pages.outputs.base_path }}
59 |
60 | - name: Upload artifact
61 | uses: actions/upload-pages-artifact@v3
62 | if: github.event_name == 'push'
63 | with:
64 | path: ./docs/dist
65 |
66 | deploy:
67 | environment:
68 | name: github-pages
69 | url: ${{ steps.deployment.outputs.page_url }}
70 | runs-on: ubuntu-latest
71 | needs: build
72 | if: github.event_name == 'push'
73 | steps:
74 | - name: Deploy to GitHub Pages
75 | id: deployment
76 | uses: actions/deploy-pages@v4
77 |
--------------------------------------------------------------------------------
/test/loader/inject-script.js:
--------------------------------------------------------------------------------
1 | import assert from "node:assert";
2 | import {describe, it, beforeEach} from "mocha";
3 | import sinon from "sinon";
4 | import {loader} from "../../chrome-extension/loader.js";
5 | import {EXT_CSS, EXT_JS} from "../../chrome-extension/path.js";
6 | import {browser} from "../../chrome-extension/browser.js";
7 |
8 | describe("Inject script", function () {
9 |
10 | beforeEach(function () {
11 | sinon.restore();
12 | });
13 |
14 | const tabUrl = "https://bar.com";
15 | const tabId = 10;
16 | const frameId = 20;
17 |
18 | [EXT_JS, EXT_CSS].forEach(type => {
19 | const script = {
20 | type: type,
21 | url: "https://foo.com",
22 | contents: "/** foo **/",
23 | };
24 |
25 | it(`inject ${type}`, async function () {
26 | await runTest({
27 | injectJs: sinon.fake(),
28 | injectCss: sinon.fake(),
29 | getTabUrl: sinon.fake.resolves(tabUrl),
30 | });
31 | });
32 |
33 | it(`fails to get tab URL when injecting ${type}`, async function () {
34 | await runTest({
35 | injectJs: sinon.fake(),
36 | injectCss: sinon.fake(),
37 | // failing to get tab URL should not prevent script from loading
38 | getTabUrl: sinon.fake.rejects(),
39 | });
40 | });
41 |
42 | async function runTest(mockBrowser) {
43 | for (const [fn, fake] of Object.entries(mockBrowser)) {
44 | sinon.replace(browser, fn, fake);
45 | }
46 |
47 | await loader.injectScript(script, tabId, frameId);
48 |
49 | if (type === EXT_JS) {
50 | assert(mockBrowser.injectCss.notCalled);
51 | assert(mockBrowser.injectJs.calledOnce);
52 | assert(mockBrowser.injectJs.calledWithExactly(script.contents, tabId, frameId));
53 | } else if (type === EXT_CSS) {
54 | assert(mockBrowser.injectJs.notCalled);
55 | assert(mockBrowser.injectCss.calledOnce);
56 | assert(mockBrowser.injectCss.calledWithExactly(script.contents, tabId, frameId));
57 | }
58 | assert(mockBrowser.getTabUrl.calledOnce);
59 | assert(mockBrowser.getTabUrl.calledWithExactly(tabId));
60 | }
61 | });
62 | });
63 |
--------------------------------------------------------------------------------
/test/util/embed-script.js:
--------------------------------------------------------------------------------
1 | import vm from "node:vm";
2 | import assert from "node:assert";
3 | import {describe, it} from "mocha";
4 | import sinon from "sinon";
5 | import {util} from "../../chrome-extension/util/index.js";
6 |
7 | describe("Embed script", function () {
8 |
9 | beforeEach(function () {
10 | sinon.restore();
11 | });
12 |
13 | it("simple embed", function () {
14 | // step 1: prepare script to embed
15 |
16 | const contents = `
17 | window.x = 123;
18 | `;
19 | const code = util.embedScript(contents);
20 |
21 | // step 2: pass it to the content script execution environment
22 |
23 | const script = {
24 | text: "",
25 | }
26 |
27 | const appendChildFn = sinon.fake();
28 |
29 | const document = {
30 | createElement: sinon.fake.returns(script),
31 | documentElement: {
32 | appendChild: appendChildFn,
33 | }
34 | };
35 |
36 | const context = {
37 | document,
38 | }
39 |
40 | // this simulates the content script execution environment injecting the script tag into the DOM
41 | vm.runInNewContext(code, context);
42 |
43 | assert(appendChildFn.calledOnce);
44 |
45 | // step 3: pass it to the page execution environment
46 |
47 | const scriptElement = appendChildFn.getCall(0).firstArg;
48 | const injectedCode = scriptElement.text;
49 |
50 | // this simulates the page execution environment running the injected code
51 | const pageContext = {
52 | window: {},
53 | }
54 | vm.runInNewContext(injectedCode, pageContext);
55 |
56 | assert.strictEqual(pageContext.window.x, 123);
57 | });
58 |
59 | it("injector", function () {
60 | const script = {
61 | text: "",
62 | }
63 |
64 | const appendChildFn = sinon.fake();
65 |
66 | const document = {
67 | createElement: sinon.fake.returns(script),
68 | documentElement: {
69 | appendChild: appendChildFn,
70 | }
71 | };
72 |
73 | util.injector(document);
74 |
75 | assert(document.documentElement.appendChild.calledOnce);
76 | assert(document.documentElement.appendChild.getCall(0).firstArg === script)
77 |
78 | return script.text;
79 | });
80 | });
81 |
--------------------------------------------------------------------------------
/test/integration/utils/dummy-web-server.js:
--------------------------------------------------------------------------------
1 | import http from 'http';
2 |
3 | class DummyWebServer {
4 | constructor() {
5 | this.server = null;
6 | this.port = null;
7 | this.pages = new Map();
8 | this.cspPolicy = null; // Add CSP policy support
9 | }
10 |
11 | addPage(pagePath, pageContents) {
12 | this.pages.set(pagePath, pageContents);
13 | }
14 |
15 | // Add method to set CSP policy
16 | setCSPPolicy(policy) {
17 | this.cspPolicy = policy;
18 | }
19 |
20 | async start() {
21 | return new Promise((resolve, reject) => {
22 | this.server = http.createServer((req, res) => {
23 | if (this.pages.has(req.url)) {
24 | let contentType = 'text/html';
25 | if (req.url.endsWith('.css')) {
26 | contentType = 'text/css';
27 | }
28 |
29 | const headers = { 'Content-Type': contentType };
30 |
31 | // Add CSP header if policy is set
32 | if (this.cspPolicy) {
33 | headers['Content-Security-Policy'] = this.cspPolicy;
34 | }
35 |
36 | res.writeHead(200, headers);
37 | res.end(this.pages.get(req.url));
38 | } else {
39 | res.writeHead(404, { 'Content-Type': 'text/plain' });
40 | res.end('Not Found');
41 | }
42 | });
43 |
44 | this.server.listen(0, () => {
45 | this.port = this.server.address().port;
46 | console.log(`DummyWebServer is listening on port ${this.port}`);
47 | resolve(this.port);
48 | });
49 |
50 | this.server.on('error', (err) => {
51 | reject(err);
52 | });
53 | });
54 | }
55 |
56 | async stop() {
57 | return new Promise((resolve, reject) => {
58 | if (this.server) {
59 | this.server.close((err) => {
60 | if (err) {
61 | reject(err);
62 | } else {
63 | this.server = null;
64 | this.port = null;
65 | resolve();
66 | }
67 | });
68 | } else {
69 | resolve();
70 | }
71 | });
72 | }
73 | }
74 |
75 | export default DummyWebServer;
76 |
--------------------------------------------------------------------------------
/test/loader/load-scripts.js:
--------------------------------------------------------------------------------
1 | import { describe, it } from "mocha";
2 | import sinon from "sinon";
3 | import {loader} from "../../chrome-extension/loader.js";
4 | import {util} from "../../chrome-extension/util/index.js";
5 | import {FETCH_RESPONSE_OUTCOME} from "../../chrome-extension/util/fetch-script.js";
6 | import {DEFAULT_SERVER_ADDRESS} from "../../chrome-extension/constants.js";
7 | import {browser} from "../../chrome-extension/browser.js";
8 |
9 | describe("Load scripts", function () {
10 |
11 | beforeEach(function () {
12 | sinon.restore();
13 | });
14 |
15 | it("load simple scripts", async function () {
16 | const url = "https://google.com";
17 | const tabId = 123;
18 | const frameId = 456;
19 |
20 | sinon.replace(browser, "retrieveKey", async (key) => {
21 | switch (key) {
22 | case "server-address":
23 | return DEFAULT_SERVER_ADDRESS;
24 | default:
25 | return null;
26 | }
27 | });
28 | sinon.replace(browser, "removeKey", async () => {});
29 | sinon.replace(browser, "storeKey", async () => {});
30 | sinon.replace(browser, "setBadgeText", async () => {});
31 |
32 | const fetchScript = sinon.stub(util, "fetchScript");
33 | fetchScript
34 | .withArgs(`${DEFAULT_SERVER_ADDRESS}/_global.js`)
35 | .returns({
36 | outcome: FETCH_RESPONSE_OUTCOME.SUCCESS,
37 | contents: `console.info("global");`,
38 | })
39 | .withArgs(`${DEFAULT_SERVER_ADDRESS}/google.com.js`)
40 | .returns({
41 | outcome: FETCH_RESPONSE_OUTCOME.SUCCESS,
42 | contents: `console.info("google.com");`,
43 | });
44 | // all other script attempts will return 404
45 | fetchScript.returns({
46 | outcome: FETCH_RESPONSE_OUTCOME.NOT_FOUND,
47 | contents: undefined,
48 | });
49 |
50 | const sendScript = sinon.stub(loader, "injectScript");
51 |
52 | await loader.loadScripts(url, tabId, frameId);
53 |
54 | sinon.assert.calledTwice(sendScript);
55 | sinon.assert.calledWith(sendScript, sinon.match({
56 | path: '_global.js',
57 | type: 'js',
58 | url: `${DEFAULT_SERVER_ADDRESS}/_global.js`
59 | }), tabId, frameId);
60 | sinon.assert.calledWith(sendScript, sinon.match({
61 | path: 'google.com.js',
62 | type: 'js',
63 | url: `${DEFAULT_SERVER_ADDRESS}/google.com.js`
64 | }), tabId, frameId);
65 | });
66 | });
67 |
--------------------------------------------------------------------------------
/chrome-extension/path.js:
--------------------------------------------------------------------------------
1 |
2 | import ScriptContext from "./script/script-context.js";
3 | import {tryParseUrl} from "./url.js";
4 |
5 | export const GLOBAL_SCRIPT_NAME = "_global";
6 | export const EXT_JS = "js";
7 | export const EXT_CSS = "css";
8 |
9 | function generatePotentialScriptNames(url) {
10 | const {hostName, pathName} = tryParseUrl(url);
11 |
12 | const domains = [GLOBAL_SCRIPT_NAME, ...iterateDomainLevels(hostName)];
13 | const paths = [...iteratePathSegments(pathName)];
14 | const result = [];
15 |
16 | for (const domain of domains) {
17 | result.push(domain);
18 | for (const path of paths) {
19 | result.push(domain + path);
20 | }
21 | }
22 |
23 | return result;
24 | }
25 |
26 | /**
27 | * Maps a domain like "foo.bar.com" to ("com", "bar.com", "foo.bar.com").
28 | *
29 | * @param {string} hostName
30 | * @returns {IterableIterator
21 |
22 |
23 |
24 | Actual practical uses include getting rid of nasty ads, automating clicks and improving page layouts with your own CSS rules. You're only limited by what Javascript and CSS can do.
25 |
26 | ## Main features
27 |
28 | This is what makes Witchcraft different than other similar extensions:
29 |
30 | - **JavaScript and CSS injection**
31 |
32 | Witchcraft is not only able to run Javascript, but also CSS;
33 |
34 | - **editing scripts is easy**
35 |
36 | Just edit your scripts in your favorite editor and refresh the page;
37 |
38 | - **cross-platform**
39 |
40 | Since Witchcraft runs as a Chrome extension, it is runs on any platform that supports Chrome extensions (Linux, Windows, MacOS);
41 |
42 | - **runs on all Chromium-based browsers**
43 |
44 | Witchcraft should work on any Chromium-based browser that supports extensions, such as Microsoft Edge, Brave and Opera;
45 |
46 | - **runs on all frames**
47 |
48 | Witchcraft will inject your scripts into all frames of the page, including iframes;
49 |
50 | - **runs on the page's context**
51 |
52 | Witchcraft runs your scripts in the page's context, giving you full access to the page's JavaScript environment;
53 |
54 | - **open source**
55 |
56 | Witchcraft is open source and available on [GitHub](https://github.com/luciopaiva/witchcraft). That means you can load it directly into Chrome as an unpacked extension, if you prefer (which is not the case for other popular extensions like Tampermonkey, which was turned closed source).
57 |
58 | For a deeper dive into Witchcraft's features, check out the [user guide](/how-to-use).
59 |
--------------------------------------------------------------------------------
/chrome-extension/icon.js:
--------------------------------------------------------------------------------
1 |
2 | import {browser} from "./browser.js";
3 | import {storage} from "./storage/index.js";
4 | import {SERVER_PING_PERIOD_IN_MILLIS} from "./constants.js";
5 | import {util} from "./util/index.js";
6 |
7 | const ICON_SIZE = 16;
8 | const ICON_SERVER_ON_NAME = "server-on";
9 | const ICON_SERVER_OFF_NAME = "server-off";
10 |
11 | async function createIcons() {
12 | const image = await loadImage("/witch-16.png");
13 | await makeIconWithStatusColor(image, "#00ff00", icon.ICON_SERVER_ON_NAME);
14 | await makeIconWithStatusColor(image, "#ff0000", icon.ICON_SERVER_OFF_NAME);
15 | }
16 |
17 | async function loadImage(path) {
18 | const response = await fetch(browser.getFileUrl(path));
19 | const blob = await response.blob();
20 | return await createImageBitmap(blob);
21 | }
22 |
23 | async function makeIconWithStatusColor(baseImage, color, iconKey) {
24 | const canvas = new OffscreenCanvas(icon.ICON_SIZE, icon.ICON_SIZE);
25 | const ctx = canvas.getContext("2d");
26 | ctx.drawImage(baseImage, 0, 0);
27 |
28 | ctx.fillStyle = color;
29 | ctx.beginPath();
30 | const size = 3;
31 | ctx.fillRect(icon.ICON_SIZE - size, 0, size, size);
32 | ctx.fill();
33 |
34 | const imageData = ctx.getImageData(0, 0, icon.ICON_SIZE, icon.ICON_SIZE);
35 | await storage.storeIcon(iconKey, imageData.data);
36 | }
37 |
38 | async function initialize() {
39 | await icon.createIcons();
40 | await icon.updateServerStatus();
41 | setInterval(icon.updateServerStatus, SERVER_PING_PERIOD_IN_MILLIS);
42 | }
43 |
44 | const cache = new Map();
45 |
46 | async function loadIcon(name) {
47 | let imageData = cache.get(name);
48 | if (!imageData) {
49 | const data = await storage.retrieveIcon(name);
50 | imageData = new ImageData(data, icon.ICON_SIZE, icon.ICON_SIZE);
51 | cache.set(name, imageData);
52 | } else {
53 | }
54 | return imageData;
55 | }
56 |
57 | async function loadServerOffIcon() {
58 | return await icon.loadIcon(icon.ICON_SERVER_OFF_NAME);
59 | }
60 |
61 | async function loadServerOnIcon() {
62 | return await icon.loadIcon(icon.ICON_SERVER_ON_NAME);
63 | }
64 |
65 | async function updateServerStatus() {
66 | const isOnline = await util.ping();
67 | const imageData = isOnline ? await icon.loadServerOnIcon() : await icon.loadServerOffIcon();
68 | await browser.setIcon(imageData);
69 | await storage.storeServerStatus(isOnline);
70 | }
71 |
72 | export const icon = {
73 | ICON_SERVER_ON_NAME,
74 | ICON_SERVER_OFF_NAME,
75 | ICON_SIZE,
76 | createIcons,
77 | loadIcon,
78 | loadServerOnIcon,
79 | loadServerOffIcon,
80 | initialize,
81 | updateServerStatus,
82 | };
83 |
--------------------------------------------------------------------------------
/development.md:
--------------------------------------------------------------------------------
1 |
2 | # Development
3 |
4 | Node.js is required, but just to run tests. `nvm` is recommended to manage Node.js versions, but not required (just make sure your Node.js version is similar to the one `.nvmrc` currently points to). To install test dependencies:
5 |
6 | nvm i
7 | npm i
8 |
9 | Then you're ready to run the tests with coverage:
10 |
11 | npm test
12 |
13 | You can also run tests with Docker:
14 |
15 | docker build -t witchcraft-tests .
16 | docker run --rm witchcraft-tests
17 |
18 | ## Note about integration tests
19 |
20 | Integration tests use `puppeteer` to run a headless Chrome instance. One of the tests checks if the scripts server is reachable from the browser context, but Witchcraft only checks for the server once every 5 seconds, which is too slow for the test. To work around this, I've added a build step that simply copies all the code into `./dist` while also changing constants.js to make Witchcraft check for the server more frequently. This is done in `npm run build`, which is run automatically when running `npm test:integration`.
21 |
22 | I tried to use other methods of verifying whether Chrome was running by an automated test, but it ended up being simpler to just change the code for the tests. If you have a better idea, a PR is welcome.
23 |
24 | ## Running integration tests on WSL2
25 |
26 | It's possible to run integration tests on WSL2, but it requires some setup. [This SO answer](https://stackoverflow.com/a/78776116/778272) nails it:
27 |
28 | ```
29 | sudo apt update
30 | sudo apt install -y ca-certificates fonts-liberation libasound2 libatk-bridge2.0-0 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgbm1 libgcc1 libglib2.0-0 libgtk-3-0 libnspr4 libnss3 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 lsb-release wget xdg-utils
31 | ```
32 |
33 | After installing all these packages, you should be able to run the following test:
34 |
35 | ```
36 | npm i puppeteer # nodejs +14
37 | cat <
76 |
77 |
78 |
79 | You may add multiple directives to the same script. If your script name contains spaces, you can use double quotes to refer to it, like in the example above.
80 |
81 | It is possible to include remote scripts as well:
82 |
83 |
84 |
85 | Useful for loading scripts from gists, for example.
86 |
87 | Included scripts can also `@include` scripts of their own; the parser will recursively iterate through them. Dependency cycles (e.g., `foo.js` includes `bar.js`, which includes `foo.js`) are automatically resolved.
88 |
89 | This directive may be useful for including third-party libraries.
90 |
--------------------------------------------------------------------------------
/docs/astro.config.mjs:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'astro/config';
2 | import starlight from '@astrojs/starlight';
3 | import starlightThemeRapide from "starlight-theme-rapide";
4 |
5 | /** Quick and dirty plugin that:
6 | * - Throws an error if relative links are found
7 | * - Prepends the configured base to all absolute links (starting with /)
8 | */
9 | function remarkAbsoluteLinksSimple(base = '/') {
10 | const prefix = `[absolute-links]`;
11 | let indentLevel = 0;
12 | const indent = () => indentLevel += 2;
13 | const dedent = () => indentLevel -= 2;
14 | const log = (msg) => console.info(`${prefix}${" ".repeat(indentLevel)} ${msg}`);
15 |
16 | const normalizedBase = base.endsWith('/') ? base.slice(0, -1) : base;
17 |
18 | return (tree, file) => {
19 | log(`Processing file: ${file.path}`);
20 |
21 | function visit(node) {
22 | if (!node || typeof node !== "object") return;
23 |
24 | indent();
25 | // log(`node: type=${node.type} children=${Array.isArray(node.children) ? node.children.length : 0}`);
26 |
27 | if (node.type === "link" && typeof node.url === "string") {
28 | const url = node.url;
29 | log(`link: ${url}`);
30 |
31 | // fail on relative links
32 | if (node.url.startsWith(".")) {
33 | throw new Error(`Relative links starting with './' are not supported. Please use root-relative links starting with '/' instead. Found in file ${file.path}: ${node.url}`);
34 | }
35 |
36 | if (node.url.startsWith(base)) {
37 | throw new Error(`Do not include the base ('${base}') in links. Links should be root-relative starting with '/'. Found in file ${file.path}: ${node.url}`);
38 | }
39 |
40 | // prepend base to all absolute links (doesn't touch external links)
41 | if (node.url.startsWith('/')) {
42 | node.url = `${normalizedBase}${node.url}`;
43 | }
44 | }
45 |
46 | for (const child of node.children || []) {
47 | visit(child);
48 | }
49 |
50 | dedent();
51 | }
52 | visit(tree);
53 | }
54 | }
55 |
56 | const astroBase = process.env.ASTRO_BASE || '/witchcraft/';
57 |
58 | // https://astro.build/config
59 | export default defineConfig({
60 | site: process.env.ASTRO_SITE || 'https://luciopaiva.com',
61 | base: astroBase,
62 | markdown: {
63 | remarkPlugins: [[remarkAbsoluteLinksSimple, astroBase]],
64 | },
65 | integrations: [
66 | starlight({
67 | title: '',
68 | logo: {
69 | src: "./src/assets/title.png",
70 | },
71 | social: [{ icon: 'github', label: 'GitHub', href: 'https://github.com/luciopaiva/witchcraft' }],
72 | plugins: [starlightThemeRapide()],
73 | customCss: [
74 | './src/styles/global.css',
75 | ],
76 | sidebar: [
77 | {
78 | label: 'Guide',
79 | items: [
80 | { label: 'Introduction', slug: 'introduction' },
81 | { label: 'How to install', slug: 'how-to-install' },
82 | { label: 'How to use', slug: 'how-to-use' },
83 | { label: 'New in version 3', slug: 'new-in-v3' },
84 | { label: 'FAQ', slug: 'faq' },
85 | { label: 'Credits', slug: 'credits' },
86 | ]
87 | },
88 | {
89 | label: 'Technical notes',
90 | items: [
91 | { label: 'Architecture', slug: 'architecture' },
92 | ]
93 | }
94 | // {
95 | // label: 'Cookbook',
96 | // autogenerate: { directory: 'cookbook' },
97 | // },
98 | ],
99 | }),
100 | ],
101 | });
102 |
--------------------------------------------------------------------------------
/docs/src/content/docs/how-to-install.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: How to install
3 | ---
4 |
5 | import { Image } from 'astro:assets';
6 | import { Steps, Tabs, TabItem } from '@astrojs/starlight/components';import serverOnlineImage from "../../assets/server-online.png";
7 |
8 | Follow these steps:
9 |
10 |
31 |
32 | ## Can't Witchcraft spawn its own local web server?
33 |
34 | Unfortunately, no. See question above.
35 |
36 | ## When exactly my script gets loaded?
37 |
38 | As of v3.3.0, it is loaded at `document_start` via a content script. This is the earliest moment you can inject a script into a page. At this point, the DOM is not fully built yet, so your script should not rely on DOM elements being present. If you need to wait for the DOM to be ready, you can listen for the `DOMContentLoaded` event inside your script, but first check `document.readyState` to see if the event has already fired:
39 |
40 | ```js
41 | if (document.readyState === 'loading') {
42 | // DOM is still loading, so let's wait for it
43 | document.addEventListener('DOMContentLoaded', () => {
44 | // DOM is ready now
45 | });
46 | } else {
47 | // DOM is already ready
48 | }
49 | ```
50 |
51 | ## My script seems to be loading multiple times. What is wrong?
52 |
53 | Your page probably has frames/iframes in it. By design, Witchcraft loads itself into every frame it can find. If you want to run your script in a specific frame, you need to enforce that inside your script. For instance, you may want your script to run only in the top frame. In that case, just check if `window.self === window.top`. More details here.
54 |
55 | ## How to load Witchcraft only in the topmost window?
56 |
57 | See question above.
58 |
59 | ## How do I inject scripts into `localhost`?
60 |
61 | In case you are developing a web page and testing it using a local web server, you can inject code in it by naming your script `localhost.js`/`localhost.css`. If you're running multiple web servers on different ports and need to tell them apart, do it by checking the port inside your script (check the `location.port` property). See an example here.
62 |
63 | ## How can I inject scripts when accessing a website via an IP address instead of a domain name?
64 |
65 | Just name your script using the IP address. For instance, if you want to load a script when accessing `http://10.0.0.1`, name your script `10.0.0.1.js`.
66 |
67 | ## Can I install Witchcraft from sources instead?
68 |
69 | Yes, you can install Witchcraft directly from sources instead of downloading it from the Chrome store. Look for the extension source under the folder `/chrome-extension` and manually load it as an unpacked extension. Check the Chrome documentation [here](https://developer.chrome.com/docs/extensions/get-started/tutorial/hello-world#load-unpacked).
70 |
71 | ## Is Google Analytics being used? Why?
72 |
73 | Yes. It is being used to track actions performed inside the Witchcraft pop-up window (.e.g: "user clicked the report issue button") and to collect anonymous statistics (e.g.: how many times was a JS include directive triggered) in order to help improve the extension.
74 |
75 | Code is carefully tailored to make sure no user-identifiable information is sent to GA. For instance, URLs being accessed and script names are not sent to GA in any way. People are encouraged to verify the code themselves and also load the extension right from the sources if they don't trust the Chrome Web Store package.
76 |
77 | ## How can I load other resources in my scripts, like images?
78 |
79 | There are three proposed ways to do it here.
80 |
81 | ## Why is my script not updating when I reload the page?
82 |
83 | Reloading the page should be enough to update the script. If that's not the case, check if your HTTP server might be caching it on the server side. Try some of the [recommended ways to run a local web server](/how-to-install#web-servers) to see if it solves the problem.
84 |
--------------------------------------------------------------------------------
/test/script/find-include-directive.js:
--------------------------------------------------------------------------------
1 |
2 | import assert from "assert";
3 | import { describe, it } from "mocha";
4 | import {script} from "../../chrome-extension/script/index.js";
5 | import {EXT_CSS, EXT_JS} from "../../chrome-extension/path.js";
6 | import sinon from "sinon";
7 |
8 | describe("Find include directive", function () {
9 |
10 | beforeEach(function () {
11 | sinon.restore();
12 | });
13 |
14 | it("empty script", function () {
15 | const js = "";
16 | const result = script.findIncludeDirective(js, EXT_JS);
17 | assert.strictEqual(result, undefined);
18 | });
19 |
20 | it("no directive", function () {
21 | const js = `
22 | // foo
23 | let x = 10;
24 | `;
25 | const result = script.findIncludeDirective(js, EXT_JS);
26 | assert.strictEqual(result, undefined);
27 | });
28 |
29 | it("single line comment, bare JS include", function () {
30 | const js = `
31 | // @include my-script.js
32 | let x = 10;
33 | `;
34 | const result = script.findIncludeDirective(js, EXT_JS);
35 | assert.strictEqual(result.startIndex, 11);
36 | assert.strictEqual(result.endIndex, 35);
37 | assert.strictEqual(result.script.path, "my-script.js");
38 | assert.strictEqual(result.script.type, EXT_JS);
39 | });
40 |
41 | it("single line comment after code, bare JS include", function () {
42 | const js = `let x = 10; // @include my-script.js`;
43 | const result = script.findIncludeDirective(js, EXT_JS);
44 | assert.strictEqual(result.startIndex, 12);
45 | assert.strictEqual(result.endIndex, 36);
46 | assert.strictEqual(result.script.path, "my-script.js");
47 | assert.strictEqual(result.script.type, EXT_JS);
48 | });
49 |
50 | it("single line comment, bare remote JS include", function () {
51 | const js = `
52 | // @include https://raw.githubusercontent.com/luciopaiva/foo/master/bar.js
53 | let x = 10;
54 | `;
55 | const result = script.findIncludeDirective(js, EXT_JS);
56 | assert.strictEqual(result.startIndex, 11);
57 | assert.strictEqual(result.endIndex, 85);
58 | assert.strictEqual(result.script.path, "https://raw.githubusercontent.com/luciopaiva/foo/master/bar.js");
59 | assert.strictEqual(result.script.type, EXT_JS);
60 | });
61 |
62 | it("multi line comment, bare JS include", function () {
63 | const js = `
64 | /* @include my-script.js */
65 | let x = 10;
66 | `;
67 | const result = script.findIncludeDirective(js, EXT_JS);
68 | assert.strictEqual(result.startIndex, 11);
69 | assert.strictEqual(result.endIndex, 38);
70 | assert.strictEqual(result.script.path, "my-script.js");
71 | assert.strictEqual(result.script.type, EXT_JS);
72 | });
73 |
74 | it("single line comment, quoted JS include", function () {
75 | const js = `
76 | // @include "my script.js"
77 | let x = 10;
78 | `;
79 | const result = script.findIncludeDirective(js, EXT_JS);
80 | assert.strictEqual(result.startIndex, 11);
81 | assert.strictEqual(result.endIndex, 37);
82 | assert.strictEqual(result.script.path, "my script.js");
83 | assert.strictEqual(result.script.type, EXT_JS);
84 | });
85 |
86 | it("multi line comment, quoted JS include", function () {
87 | const js = `
88 | /* @include "my script.js" */
89 | let x = 10;
90 | `;
91 | const result = script.findIncludeDirective(js, EXT_JS);
92 | assert.strictEqual(result.startIndex, 11);
93 | assert.strictEqual(result.endIndex, 40);
94 | assert.strictEqual(result.script.path, "my script.js");
95 | assert.strictEqual(result.script.type, EXT_JS);
96 | });
97 |
98 | it("bare CSS include", function () {
99 | const js = `
100 | a {}
101 | /* @include my-script.css */
102 | body {
103 | background-color: black;
104 | }
105 | `;
106 | const result = script.findIncludeDirective(js, EXT_CSS);
107 | assert.strictEqual(result.startIndex, 26);
108 | assert.strictEqual(result.endIndex, 54);
109 | assert.strictEqual(result.script.path, "my-script.css");
110 | assert.strictEqual(result.script.type, EXT_CSS);
111 | });
112 |
113 | it("quoted CSS include", function () {
114 | const js = `
115 | a {}
116 | /* @include "my script.css" */
117 | body {
118 | background-color: black;
119 | }
120 | `;
121 | const result = script.findIncludeDirective(js, EXT_JS);
122 | assert.strictEqual(result.startIndex, 26);
123 | assert.strictEqual(result.endIndex, 56);
124 | assert.strictEqual(result.script.path, "my script.css");
125 | assert.strictEqual(result.script.type, EXT_JS);
126 | });
127 |
128 | it("quoted remote CSS include", function () {
129 | const js = `
130 | a {}
131 | /* @include "https://raw.githubusercontent.com/luciopaiva/foo/master/bar.css" */
132 | body {
133 | background-color: black;
134 | }
135 | `;
136 | const result = script.findIncludeDirective(js, EXT_JS);
137 | assert.strictEqual(result.startIndex, 26);
138 | assert.strictEqual(result.endIndex, 106);
139 | assert.strictEqual(result.script.path, "https://raw.githubusercontent.com/luciopaiva/foo/master/bar.css");
140 | assert.strictEqual(result.script.type, EXT_JS);
141 | });
142 | });
143 |
--------------------------------------------------------------------------------
/chrome-extension/loader.js:
--------------------------------------------------------------------------------
1 | import {script as scripts} from "./script/index.js";
2 | import {FETCH_RESPONSE_OUTCOME} from "./util/fetch-script.js";
3 | import {util} from "./util/index.js";
4 | import {EXT_CSS, EXT_JS} from "./path.js";
5 | import {browser} from "./browser.js";
6 | import {storage} from "./storage/index.js";
7 | import path from "./path.js";
8 | import {badge} from "./badge.js";
9 | import Metrics from "./analytics/metrics.js";
10 |
11 | /**
12 | * Fetches scripts from the server and injects those that were found.
13 | *
14 | * The input script order is guaranteed to be respected. This function will cleverly fetch all scripts in parallel, but
15 | * will inject them in the intended order. This means that if a given script S takes longer to fetch (e.g., a remote
16 | * file), all subsequent script injections will hold until S is either downloaded and fetched or failed (in case of
17 | * HTTP 404).
18 | *
19 | * @param {Script[]} scripts
20 | * @param {number} tabId
21 | * @param {number} frameId
22 | * @returns {Promise