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; +}