diff --git a/source/assets/options/index.html b/source/assets/options/index.html
new file mode 100644
index 0000000..e59c52b
--- /dev/null
+++ b/source/assets/options/index.html
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+ Tildes Shepherd
+
+
+
+
+
+
+
+
+
+
diff --git a/source/assets/tildes-shepherd.png b/source/assets/tildes-shepherd.png
new file mode 100644
index 0000000..68d2681
Binary files /dev/null and b/source/assets/tildes-shepherd.png differ
diff --git a/source/assets/tildes-shepherd.svg b/source/assets/tildes-shepherd.svg
new file mode 100644
index 0000000..6d833d1
--- /dev/null
+++ b/source/assets/tildes-shepherd.svg
@@ -0,0 +1,13 @@
+
diff --git a/source/background/setup.ts b/source/background/setup.ts
new file mode 100644
index 0000000..c1a1e6c
--- /dev/null
+++ b/source/background/setup.ts
@@ -0,0 +1,27 @@
+// The main entry point for the background script. Note that in Manifest V3 this
+// is run in a service worker.
+// https://developer.chrome.com/docs/extensions/migrating/to-service-workers/
+
+import browser from "webextension-polyfill";
+
+if ($browser === "firefox") {
+ browser.browserAction.onClicked.addListener(async () => {
+ await browser.runtime.openOptionsPage();
+ });
+} else {
+ browser.action.onClicked.addListener(async () => {
+ await browser.runtime.openOptionsPage();
+ });
+}
+
+browser.runtime.onInstalled.addListener(async () => {
+ if (!$dev) {
+ await browser.runtime.openOptionsPage();
+ }
+});
+
+browser.runtime.onMessage.addListener(async (message) => {
+ if (message === "open-options-page") {
+ await browser.runtime.openOptionsPage();
+ }
+});
diff --git a/source/build.ts b/source/build.ts
new file mode 100644
index 0000000..6cdca6a
--- /dev/null
+++ b/source/build.ts
@@ -0,0 +1,120 @@
+import path from "node:path";
+import process from "node:process";
+import fsp from "node:fs/promises";
+import esbuild from "esbuild";
+import copyPlugin from "esbuild-copy-static-files";
+import {sassPlugin, type SassPluginOptions} from "esbuild-sass-plugin";
+import cssnano from "cssnano";
+import postcss from "postcss";
+import {createManifest} from "./manifest.js";
+import {createWebExtConfig} from "./web-ext.js";
+
+/**
+ * Create an absolute path from a given relative one, using the directory
+ * this file is located in as the base.
+ *
+ * @param relative The relative path to make absolute.
+ * @returns The absolute path.
+ */
+function toAbsolutePath(relative: string): string {
+ return new URL(relative, import.meta.url).pathname;
+}
+
+// Create variables based on the environment.
+const browser = process.env.BROWSER ?? "firefox";
+const dev = process.env.NODE_ENV === "development";
+const test = process.env.TEST === "true";
+const watch = process.env.WATCH === "true";
+
+// Create absolute paths to various directories.
+const buildDir = toAbsolutePath("../build");
+const outDir = path.join(buildDir, browser);
+const sourceDir = toAbsolutePath("../source");
+
+// Ensure that the output directory exists.
+await fsp.mkdir(outDir, {recursive: true});
+
+// Write the WebExtension manifest file.
+await fsp.writeFile(
+ path.join(outDir, "manifest.json"),
+ JSON.stringify(createManifest(browser)),
+);
+
+// Write the web-ext configuration file.
+await fsp.writeFile(
+ path.join(buildDir, `web-ext-${browser}.json`),
+ JSON.stringify(createWebExtConfig(browser, buildDir, dev, outDir)),
+);
+
+const cssProcessor = postcss([cssnano()]);
+
+const createSassPlugin = (type: SassPluginOptions["type"]) => {
+ return sassPlugin({
+ type,
+ async transform(source) {
+ // In development, don't do any extra processing.
+ if (dev) {
+ return source;
+ }
+
+ // But in production, run the CSS through PostCSS.
+ const {css} = await cssProcessor.process(source, {from: undefined});
+ return css;
+ },
+ });
+};
+
+const options: esbuild.BuildOptions = {
+ bundle: true,
+ // Define variables to be replaced in the code. Note that these are replaced
+ // "as is" and so we have to stringify them as JSON, otherwise a string won't
+ // have its quotes for example.
+ define: {
+ $browser: JSON.stringify(browser),
+ $dev: JSON.stringify(dev),
+ $test: JSON.stringify(test),
+ },
+ entryPoints: [
+ path.join(sourceDir, "background/setup.ts"),
+ path.join(sourceDir, "options/setup.tsx"),
+ path.join(sourceDir, "content-scripts/setup.ts"),
+ ],
+ format: "esm",
+ logLevel: "info",
+ minify: !dev,
+ outdir: outDir,
+ plugins: [
+ // Copy all files from `source/assets/` to the output directory.
+ copyPlugin({src: path.join(sourceDir, "assets/"), dest: outDir}),
+ // Compile SCSS to CSS.
+ createSassPlugin("style"),
+ ],
+ // Link sourcemaps in development but omit them in production.
+ sourcemap: dev ? "linked" : false,
+ // Currently code splitting can't be used because we use ES modules and
+ // Firefox doesn't run the background script with `type="module"`.
+ // Once Firefox properly supports Manifest V3 this should be possible though.
+ splitting: false,
+ // Target ES2022, and the first Chromium and Firefox releases from 2022.
+ target: ["es2022", "chrome97", "firefox102"],
+ treeShaking: true,
+};
+
+const contentStyleOptions: esbuild.BuildOptions = {
+ entryPoints: [path.join(sourceDir, "scss/shepherd/shepherd.scss")],
+ logLevel: options.logLevel,
+ minify: options.minify,
+ outfile: path.join(outDir, "css/shepherd.css"),
+ plugins: [createSassPlugin("css")],
+ sourcemap: options.sourcemap,
+ target: options.target,
+};
+
+if (watch) {
+ const context = await esbuild.context(options);
+ const contentStyleContext = await esbuild.context(contentStyleOptions);
+ await Promise.all([context.watch(), contentStyleContext.watch()]);
+} else {
+ await esbuild.build(options);
+ await esbuild.build(contentStyleOptions);
+}
diff --git a/source/manifest.ts b/source/manifest.ts
new file mode 100644
index 0000000..aac3e06
--- /dev/null
+++ b/source/manifest.ts
@@ -0,0 +1,69 @@
+/* eslint-disable @typescript-eslint/naming-convention */
+
+import {type Manifest} from "webextension-polyfill";
+
+/**
+ * Creates the WebExtension manifest based on the browser target.
+ *
+ * @param browser The browser target ("firefox" or "chromium").
+ * @returns The WebExtension manifest.
+ */
+export function createManifest(browser: string): Manifest.WebExtensionManifest {
+ const manifest: Manifest.WebExtensionManifest = {
+ manifest_version: Number.NaN,
+ name: "Tildes Shepherd",
+ version: "0.1.0",
+ permissions: ["storage"],
+ options_ui: {
+ page: "options/index.html",
+ open_in_tab: true,
+ },
+ content_scripts: [
+ {
+ css: ["css/shepherd.css"],
+ js: ["content-scripts/setup.js"],
+ matches: ["https://*.tildes.net/*"],
+ run_at: "document_start",
+ },
+ ],
+ };
+
+ const icons: Manifest.IconPath = {
+ 128: "tildes-shepherd.png",
+ };
+
+ const action: Manifest.ActionManifest = {
+ default_icon: icons,
+ };
+
+ const backgroundScript = "background/setup.js";
+
+ if (browser === "firefox") {
+ manifest.manifest_version = 2;
+ manifest.background = {
+ scripts: [backgroundScript],
+ };
+ manifest.browser_action = action;
+ manifest.browser_specific_settings = {
+ gecko: {
+ // TODO: Add the AMO ID once it has been published.
+ strict_min_version: "102.0",
+ },
+ };
+ } else if (browser === "chromium") {
+ manifest.manifest_version = 3;
+ manifest.action = action;
+ manifest.background = {
+ service_worker: backgroundScript,
+ type: "module",
+ };
+ } else {
+ throw new Error(`Unknown target browser: ${browser}`);
+ }
+
+ if (Number.isNaN(manifest.manifest_version)) {
+ throw new TypeError("Manifest version is NaN");
+ }
+
+ return manifest;
+}
diff --git a/source/packages.d.ts b/source/packages.d.ts
new file mode 100644
index 0000000..4311bd7
--- /dev/null
+++ b/source/packages.d.ts
@@ -0,0 +1,15 @@
+// Type definitions for third-party packages.
+
+declare module "esbuild-copy-static-files" {
+ import {type cpSync} from "node:fs";
+ import {type Plugin} from "esbuild";
+
+ type CopySyncParameters = Parameters;
+
+ type Options = {
+ src?: CopySyncParameters[0];
+ dest?: CopySyncParameters[1];
+ } & CopySyncParameters[2];
+
+ export default function (options: Options): Plugin;
+}
diff --git a/source/types.d.ts b/source/types.d.ts
new file mode 100644
index 0000000..7a154ec
--- /dev/null
+++ b/source/types.d.ts
@@ -0,0 +1,8 @@
+// Export something so TypeScript doesn't see this file as an ambient module.
+export {};
+
+declare global {
+ const $browser: "chromium" | "firefox";
+ const $dev: boolean;
+ const $test: boolean;
+}
diff --git a/source/web-ext.ts b/source/web-ext.ts
new file mode 100644
index 0000000..2172bc9
--- /dev/null
+++ b/source/web-ext.ts
@@ -0,0 +1,75 @@
+import path from "node:path";
+
+/**
+ * Barebones type definition for web-ext configuration.
+ *
+ * Since web-ext doesn't export any types this is done by ourselves. The keys
+ * mostly follow a camelCased version of the CLI options
+ * (ie. --start-url becomes startUrl).
+ */
+type WebExtConfig = {
+ artifactsDir: string;
+ sourceDir: string;
+ verbose?: boolean;
+
+ build: {
+ filename: string;
+ overwriteDest: boolean;
+ };
+
+ run: {
+ browserConsole: boolean;
+ firefoxProfile: string;
+ keepProfileChanges: boolean;
+ profileCreateIfMissing: boolean;
+ startUrl: string[];
+ target: string[];
+ };
+};
+
+/**
+ * Create the web-ext configuration.
+ *
+ * @param browser The browser target ("firefox" or "chromium").
+ * @param buildDir The path to the build directory.
+ * @param dev Is this for development or production.
+ * @param outDir The path to the output directory.
+ * @returns The configuration for web-ext.
+ */
+export function createWebExtConfig(
+ browser: string,
+ buildDir: string,
+ dev: boolean,
+ outDir: string,
+): WebExtConfig {
+ const config: WebExtConfig = {
+ artifactsDir: path.join(buildDir, "artifacts"),
+ sourceDir: outDir,
+
+ build: {
+ filename: `{name}-{version}-${browser}.zip`,
+ overwriteDest: true,
+ },
+
+ run: {
+ browserConsole: dev,
+ firefoxProfile: path.join(buildDir, "firefox-profile/"),
+ keepProfileChanges: true,
+ profileCreateIfMissing: true,
+ startUrl: [],
+ target: [],
+ },
+ };
+
+ if (browser === "firefox") {
+ config.run.startUrl.push("about:debugging#/runtime/this-firefox");
+ config.run.target.push("firefox-desktop");
+ } else if (browser === "chromium") {
+ config.run.startUrl.push("chrome://extensions/");
+ config.run.target.push("chromium");
+ } else {
+ throw new Error(`Unknown target browser: ${browser}`);
+ }
+
+ return config;
+}