Merge pull request #291 from AdenMGB/main

Add comments to code
This commit is contained in:
Seth Burkart
2025-06-04 16:12:12 +10:00
committed by GitHub
22 changed files with 1126 additions and 73 deletions
+33
View File
@@ -1,8 +1,41 @@
import fs from "fs";
import mime from "mime-types";
/**
* A Vite plugin designed to load files as base64 encoded data URLs.
* This plugin intercepts module imports that have a `?base64` query parameter
* appended to the file path. It then reads the targeted file, converts its content
* to a base64 string, and constructs a data URL which is then exported as the
* default export of a new JavaScript module.
*
* @example
* // To use this loader, import a file with ?base64 query:
* // import myImageBase64 from './path/to/myimage.png?base64';
* // myImageBase64 will then be a string like "data:image/png;base64,..."
*/
export const base64Loader = {
/**
* The name of the Vite plugin.
* @type {string}
*/
name: "base64-loader",
/**
* The core transformation function of the Vite plugin.
* It is called by Vite for modules that might need transformation. This function
* checks if the module ID includes the `?base64` query. If so, it reads the
* specified file, converts it to a base64 data URL, and returns a new
* JavaScript module that default exports this data URL.
*
* @param {any} _ The original code of the file. This parameter is unused by this loader.
* @param {string} id The ID of the module being transformed. This string typically
* contains the absolute file path and any query parameters
* (e.g., "/path/to/file.png?base64").
* @returns {string | null} If the module ID does not contain `?base64` query,
* it returns `null` to indicate no transformation.
* Otherwise, it returns a string of JavaScript code
* that default exports the base64 data URL of the file.
* For example: `export default 'data:image/png;base64,xxxx';`
*/
transform(_: any, id: string) {
const [filePath, query] = id.split("?");
if (query !== "base64") return null;
+39 -6
View File
@@ -1,25 +1,58 @@
// ref: https://stackoverflow.com/a/76920975
import type { Plugin } from "vite";
/**
* Creates a Vite plugin designed to gracefully handle the conclusion of the build process.
* This plugin utilizes the `buildEnd` and `closeBundle` hooks provided by Vite.
* It checks for errors at the end of the build:
* - If an error occurred during the build (`buildEnd` hook receives an error), it logs the error
* and explicitly exits the Node.js process with a status code of 1 (indicating failure).
* - If the build completes without errors and the bundle is successfully generated
* (`closeBundle` hook is called), it logs a success message and exits the process
* with a status code of 0 (indicating success).
* This explicit process exiting can be useful in CI/CD environments or scripts that
* rely on the process status code to determine the build outcome.
* The core logic for using these hooks to exit the process is inspired by
* a solution found on StackOverflow (https://stackoverflow.com/a/76920975).
*
* @returns {Plugin} A Vite plugin object configured with `name`, `buildEnd`, and `closeBundle` hooks.
*/
export default function ClosePlugin(): Plugin {
return {
/**
* The unique name of this Vite plugin. This name is used by Vite for identification
* purposes and will appear in warnings, errors, and logs related to this plugin.
* @type {string}
*/
name: "ClosePlugin", // required, will show up in warnings and errors
// use this to catch errors when building
/**
* A Vite hook that is called when the build process has finished, regardless of
* whether it was successful or encountered an error.
*
* @param {Error} [error] An optional error object. If the build failed, this parameter
* will contain the error that occurred. If the build was successful,
* this parameter will be undefined or null.
*/
buildEnd(error) {
if (error) {
console.error("Error bundling");
console.error(error);
process.exit(1);
process.exit(1); // Exit with status 1 indicating an error
} else {
console.log("Build ended");
console.log("Build ended"); // Log successful completion of the build phase
}
},
// use this to catch the end of a build without errors
/**
* A Vite hook that is called after the `buildEnd` hook, but only if the build
* was successful (i.e., no errors were passed to `buildEnd`) and all output
* files have been generated and written to disk. This signifies the successful
* completion of the entire bundling process.
*/
closeBundle() {
console.log("Bundle closed");
process.exit(0);
console.log("Bundle closed"); // Log successful closure of the bundle
process.exit(0); // Exit with status 0 indicating a successful build
},
};
}
+23 -10
View File
@@ -1,12 +1,22 @@
import type { Browser, BuildTarget, Manifest } from "./types";
import type { AnyCase } from "./utils";
/**
*
* Packages a given manifest object with a specific target browser identifier.
* This function is typically used in multi-browser extension build processes
* to create a configuration object that pairs the manifest data with the browser
* it's intended for. The `AnyCase<Browser>` type for the browser parameter
* implies that browser names like 'chrome', 'firefox', etc., can be provided
* in various casings.
*
* @export
* @param {Manifest} manifest
* @param {AnyCase<Browser>} browser
* @return {*} {@link BuildTarget}
* @param {Manifest} manifest The core manifest data for the extension,
* compatible with `chrome.runtime.ManifestV3` as defined by the {@link Manifest} type.
* @param {AnyCase<Browser>} browser The target browser identifier (e.g., 'chrome', 'firefox', 'CHROME').
* Refers to the {@link Browser} type, allowing for flexible casing.
* @returns {BuildTarget} An object that pairs the `manifest` with its target `browser`.
* The structure is `{ manifest: Manifest; browser: AnyCase<Browser>; }`
* as defined by the {@link BuildTarget} type.
*/
export function createManifest(
manifest: Manifest,
@@ -19,14 +29,17 @@ export function createManifest(
}
/**
* create a base Manifest to inherit from
* type Manifest = chrome.runtime.ManifestV3
*
* use as shared base to extend inBrowser manifests
* Defines a base manifest object.
* This function is typically used to establish a common, shared foundation for an extension's manifest
* (compatible with `chrome.runtime.ManifestV3` as per the {@link Manifest} type).
* This base can then be extended or modified for different browsers or specific build configurations.
* For example, you might define core permissions and properties here, and then add
* browser-specific keys in subsequent steps.
*
* @export
* @param {Manifest} manifest
* @return {*} {@link Manifest}
* @param {Manifest} manifest The core manifest data to be used as a base.
* This should conform to the {@link Manifest} type structure.
* @returns {Manifest} The provided manifest object, intended to serve as a reusable base.
*/
export function createManifestBase(manifest: Manifest): Manifest {
return manifest;
+42 -9
View File
@@ -1,27 +1,60 @@
// vite-plugin-inline-worker-dev.ts
// vite-plugin-inline-worker-dev.ts
import { Plugin } from "vite";
import fs from "fs/promises";
import { build, transform } from "esbuild";
import { build } from "esbuild";
/**
* Creates a Vite plugin designed for bundling and inlining web worker scripts during development.
* This plugin specifically targets module imports that include a `?inlineWorker` query parameter.
* When such an import is encountered, the plugin bundles the worker script using `esbuild`
* and then generates JavaScript code that inlines this bundled worker as a Blob,
* creating the worker instance via `URL.createObjectURL()`.
* The name "vite:inline-worker-dev" suggests it's primarily intended for development builds.
*
* @returns {Plugin} A Vite plugin object with `name` and `load` properties.
*/
export default function InlineWorkerDevPlugin(): Plugin {
return {
/**
* The unique name of this Vite plugin.
* @type {string}
*/
name: "vite:inline-worker-dev",
/**
* The Vite hook responsible for loading and transforming modules.
* This function intercepts modules imported with `?inlineWorker`.
* For such modules, it bundles the worker script and returns JavaScript code
* that, when executed, will create an instance of this worker from an inlined Blob.
*
* @async
* @param {string} id The path or ID of the module Vite is attempting to load,
* potentially including query parameters (e.g., "/path/to/worker.ts?inlineWorker").
* @returns {Promise<string | null>} A promise that resolves to:
* - `null` if the module ID does not include `?inlineWorker`.
* - A string of JavaScript code if the module is an inline worker.
* This code will define a default export function (e.g., `InlineWorker`)
* that, when called, creates and returns a new `Worker` instance
* from the bundled and inlined worker script.
*/
async load(id) {
if (id.includes("?inlineWorker")) {
const [cleanPath] = id.split("?");
console.log("cleanPath", cleanPath);
const code = await fs.readFile(cleanPath, "utf-8");
// Note: Original code had `await fs.readFile(cleanPath, "utf-8");` but `code` wasn't used.
// `esbuild` directly takes `cleanPath` as an entry point.
const result = await build({
entryPoints: [cleanPath],
entryPoints: [cleanPath], // esbuild uses the file path directly
bundle: true,
write: false,
platform: "browser",
format: "iife",
target: "esnext",
write: false, // We want the output in memory, not written to disk
platform: "browser", // Target environment for the worker code
format: "iife", // Immediately Invoked Function Expression, suitable for workers
target: "esnext", // Transpile to modern JavaScript
});
const workerCode = result.outputFiles[0].text;
// Construct JavaScript code that will create the worker from a Blob.
// This code is what gets returned to Vite and replaces the original import.
const workerBlobCode = `
const code = ${JSON.stringify(workerCode)};
export default function InlineWorker() {
@@ -31,7 +64,7 @@ export default function InlineWorkerDevPlugin(): Plugin {
`;
return workerBlobCode;
}
return null;
return null; // Let Vite handle other modules normally
},
};
}
+101 -18
View File
@@ -1,8 +1,33 @@
/**
* @fileoverview
* This script is a command-line utility for publishing the BetterSEQTA+ extension.
* It automates the process of finding the latest built extension ZIP files for specified
* browsers, zipping the project source code (for Firefox), and then invoking the
* `publish-extension` tool with the appropriate arguments.
*
* To use this script, invoke it with Node.js followed by browser arguments:
* e.g., `node lib/publish.js --b chrome firefox`
* or `node lib/publish.js --b chrome`
* or `node lib/publish.js --b firefox`
*/
const glob = require("glob");
const semver = require("semver");
const { execSync } = require("child_process");
const path = require("path");
/**
* Determines the latest version string from a list of filenames that include version numbers.
* Filenames are expected to follow a pattern like `betterseqtaplus@3.4.5.1-chrome.zip`.
* This function handles potential 4-part versions (e.g., `3.4.5.1`) by trimming them
* to 3 parts (e.g., `3.4.5`) for comparison using the `semver` library. After identifying
* the latest semver-compatible version, it returns the original full version string
* (e.g., "3.4.5.1") that corresponds to this latest version.
*
* @param {string[]} files An array of filenames.
* @returns {string | null} The latest version string (e.g., "3.4.5.1") found among the files,
* or `null` if no valid version numbers are found or no files are provided.
*/
function getLatestVersion(files) {
console.log("Files passed to getLatestVersion:", files);
@@ -19,32 +44,56 @@ function getLatestVersion(files) {
if (!match) return null;
const fullVersion = match[1]; // Original version (e.g., 3.4.5.1)
const semverVersion = fullVersion.split(".").slice(0, 3).join("."); // Trim to 3.4.5
// Trim to 3 parts for semver comparison, as semver typically handles X.Y.Z
const semverVersion = fullVersion.split(".").slice(0, 3).join(".");
return { fullVersion, semverVersion };
})
.filter(Boolean);
.filter(Boolean); // Remove null entries if any file didn't match
console.log(
"Extracted versions:",
versions.map((v) => v.semverVersion),
);
if (versions.length === 0) {
console.log("No versions extracted.");
return null;
}
// Find latest version using the trimmed semver format
const latestSemver = semver.maxSatisfying(
versions.map((v) => v.semverVersion),
"*",
"*", // Satisfy any version, effectively finding the max
);
console.log("Latest SemVer-compatible version:", latestSemver);
// Get the full version that matches the latest SemVer version
const latestVersion =
versions.find((v) => v.semverVersion === latestSemver)?.fullVersion || null;
console.log("Final selected latest version:", latestVersion);
return latestVersion;
if (!latestSemver) {
console.log("Could not determine latest semver version.");
return null;
}
// Get the original full version string that matches the identified latest SemVer version
const latestVersionData = versions.find(
(v) => v.semverVersion === latestSemver,
);
const latestFullVersion = latestVersionData ? latestVersionData.fullVersion : null;
console.log("Final selected latest version:", latestFullVersion);
return latestFullVersion;
}
/**
* Finds the path to the latest built ZIP file for a specific browser.
* It constructs a glob pattern based on the browser name (e.g., `dist/betterseqtaplus@*-*chrome.zip`),
* finds all matching files, and then uses `getLatestVersion` to identify the version string
* of the most recent file. Finally, it returns the full path to that specific file.
*
* @param {string} browser A string indicating the target browser (e.g., "chrome", "firefox").
* @returns {string | undefined} The filepath string to the latest ZIP file for the specified browser,
* or `undefined` if no matching file is found or if the latest version
* cannot be determined.
*/
function getLatestFiles(browser) {
const pattern = `dist/betterseqtaplus@*-*${browser}.zip`;
console.log("Glob pattern:", pattern);
@@ -52,15 +101,32 @@ function getLatestFiles(browser) {
const files = glob.sync(pattern);
console.log("Files found for browser", browser, ":", files);
const latestVersion = getLatestVersion(files);
if (files.length === 0) {
console.log("No files found for browser", browser);
return undefined;
}
// Find the exact file by matching the original full version
const latestVersion = getLatestVersion(files);
if (!latestVersion) {
console.log("Could not determine latest version for browser", browser);
return undefined;
}
// Find the exact file by matching the original full version string
const latestFile = files.find((file) => file.includes(`@${latestVersion}-`));
console.log("Latest file for browser", browser, ":", latestFile);
return latestFile;
}
/**
* Creates a ZIP file of the project's source code, excluding specified development-related
* files and directories such as `node_modules`, `dist`, `.git`, etc.
* It uses the `7z` command-line tool to perform the archiving.
* The output filename is fixed as `dist/betterseqtaplus@latest-sources.zip`.
*
* @returns {string} The filename of the created ZIP file (e.g., `dist/betterseqtaplus@latest-sources.zip`).
*/
function zipSources() {
const zipFileName = `dist/betterseqtaplus@latest-sources.zip`;
@@ -74,17 +140,31 @@ function zipSources() {
"LICENSE",
"package.json",
]
.map((pattern) => `-x!${pattern}`)
.map((pattern) => `-x!${pattern}`) // Format for 7z exclude syntax
.join(" ");
// Command to zip the current directory's contents into zipFileName, applying exclude patterns
const zipCommand = `7z a ${zipFileName} . ${excludePatterns}`;
console.log("Zipping project sources with command:", zipCommand);
execSync(zipCommand, { stdio: "inherit" });
execSync(zipCommand, { stdio: "inherit" }); // Execute synchronously and show output
return zipFileName;
}
/**
* Orchestrates the extension publishing process for the specified browsers.
* This function performs the following steps:
* 1. Calls `getLatestFiles` to find the latest built ZIP for Chrome if "chrome" is in `browsers`.
* 2. Calls `getLatestFiles` to find the latest built ZIP for Firefox if "firefox" is in `browsers`.
* 3. Calls `zipSources` to create a source code ZIP if "firefox" is in `browsers` (required for Mozilla Add-ons).
* 4. Validates that all required files were found and that at least one browser was specified. Exits if not.
* 5. Constructs the `publish-extension` command-line string with the appropriate arguments
* based on the found ZIP files for the specified browsers.
* 6. Executes the constructed `publish-extension` command.
*
* @param {string[]} browsers An array of browser strings (e.g., ["chrome", "firefox"]) for which to publish the extension.
*/
function runPublishCommand(browsers) {
const chromeZip = browsers.includes("chrome")
? getLatestFiles("chrome")
@@ -92,6 +172,7 @@ function runPublishCommand(browsers) {
const firefoxZip = browsers.includes("firefox")
? getLatestFiles("firefox")
: null;
// Sources are typically only needed for Firefox submissions
const firefoxSourcesZip = browsers.includes("firefox") ? zipSources() : null;
console.log("Chrome zip:", chromeZip);
@@ -100,15 +181,16 @@ function runPublishCommand(browsers) {
if (browsers.length === 0) {
console.log("No browsers specified. Exiting.");
process.exit(0);
process.exit(0); // Exit gracefully if no action is needed
}
// Check if required files are missing for the specified browsers
if (
(browsers.includes("chrome") && !chromeZip) ||
(browsers.includes("firefox") && (!firefoxZip || !firefoxSourcesZip))
) {
console.error("Could not find required zip files for specified browsers.");
process.exit(1);
process.exit(1); // Exit with error status
}
let command = "publish-extension";
@@ -120,12 +202,13 @@ function runPublishCommand(browsers) {
}
console.log("Running command:", command);
execSync(command, { stdio: "inherit" });
execSync(command, { stdio: "inherit" }); // Execute and show output
}
// Parse command-line arguments
// Parse command-line arguments to determine which browsers to publish for
const args = process.argv.slice(2);
const browserIndex = args.indexOf("--b");
const browserIndex = args.indexOf("--b"); // Find the --b flag
// If --b is found, take all subsequent arguments as browser names
const browsers = browserIndex !== -1 ? args.slice(browserIndex + 1) : [];
runPublishCommand(browsers);
+42 -4
View File
@@ -1,17 +1,55 @@
import fs from "fs";
/**
* Creates a Vite plugin designed to improve the reliability of Hot Module Replacement (HMR)
* for global CSS files.
*
* When a JavaScript/TypeScript module that imports a CSS file is updated, Vite's HMR
* might not always reliably update the styles injected by that global CSS. This plugin
* attempts to mitigate this by listening for hot updates. If an updated module
* has direct importers that are CSS files (e.g., a JS file imports a global CSS file),
* this plugin will "touch" those CSS files by updating their access and modification
* timestamps using `fs.utimesSync`. This action can help signal to Vite or the browser
* that the CSS file has changed, potentially triggering a more reliable style reload.
*
* @returns {import('vite').Plugin} A Vite plugin object configured with `name` and `handleHotUpdate` hooks.
*/
export default function touchGlobalCSSPlugin() {
return {
/**
* The unique name of this Vite plugin.
* This name is used by Vite for identification purposes and will appear in logs.
* @type {string}
*/
name: "touch-global-css",
/**
* A Vite hook that is called when a module is hot-updated.
* This function inspects the importers of the updated module. If any of these
* importers are CSS files, their filesystem timestamps are updated ("touched").
*
* @param {object} context The context object provided by Vite's `handleHotUpdate` hook.
* @param {Array<import('vite').ModuleNode>} context.modules An array of `ModuleNode` instances that have been updated.
* This plugin specifically accesses `modules[0]._clientModule.importers`
* to find CSS files that import the updated module.
*/
handleHotUpdate({ modules }) {
// log all of the staticImportedUrls
const importers = modules[0]._clientModule.importers;
// It's assumed `modules[0]` is the primary updated module of interest.
// `_clientModule` and `importers` might be internal or less stable Vite APIs.
const importers = modules[0]?._clientModule?.importers;
if (importers) {
importers.forEach((importer) => {
if (importer.file.includes(".css")) {
console.log("touching", importer.file);
// Check if the importer is a CSS file
if (importer.file && importer.file.includes(".css")) {
console.log("[touch-global-css] touching", importer.file);
try {
// Update the access and modification times of the CSS file to the current time
fs.utimesSync(importer.file, new Date(), new Date());
} catch (err) {
console.error(`[touch-global-css] Error touching file ${importer.file}:`, err);
}
}
});
}
},
};
}
+144 -8
View File
@@ -1,6 +1,9 @@
import type { ManifestV3Export } from "@crxjs/vite-plugin";
import { type AnyCase, createEnum } from "./utils";
import { type AnyCase, createEnum, ObjectValues } from "./utils";
/**
* Enumerates supported JavaScript frameworks for project generation or configuration.
*/
export const FrameworkEnum = {
React: "React",
Vanilla: "Vanilla",
@@ -10,6 +13,9 @@ export const FrameworkEnum = {
Vue: "Vue",
} as const;
/**
* Enumerates supported web browsers, typically for targeting builds or configurations.
*/
export const BrowserEnum = {
Chrome: "Chrome",
Brave: "Brave",
@@ -19,15 +25,26 @@ export const BrowserEnum = {
Safari: "Safari",
} as const;
/**
* @private
* Enumerates supported programming languages for project setup.
* This enum is not exported, suggesting it's for internal use within this module or related modules.
*/
const LanguageEnum = {
TypeScript: "TypeScript",
JavaScript: "JavaScript",
} as const;
/**
* Enumerates supported styling options or libraries.
*/
export const StyleEnum = {
Tailwind: "Tailwind",
} as const;
/**
* Enumerates supported package managers.
*/
export const PackageManagerEnum = {
Bun: "Bun",
PnPm: "PnPm",
@@ -35,7 +52,21 @@ export const PackageManagerEnum = {
Yarn: "Yarn",
} as const;
// see: https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/firefox-webext-browser/index.d.ts
/**
* Defines the structure for browser-specific settings within a web extension manifest.
* This is particularly used for Firefox (gecko) extensions to specify properties like
* an extension ID, and minimum/maximum supported browser versions.
* The structure is based on common manifest extensions for Firefox.
* See: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/browser_specific_settings
* The link in the original code (// see: https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/firefox-webext-browser/index.d.ts)
* also points to type definitions that include this structure.
*
* @property {object} [browser_specific_settings] - Container for browser-specific settings.
* @property {object} [browser_specific_settings.gecko] - Settings specific to Gecko-based browsers (e.g., Firefox).
* @property {string} [browser_specific_settings.gecko.id] - The unique identifier for the extension in Firefox.
* @property {string} [browser_specific_settings.gecko.strict_min_version] - The minimum version of Firefox the extension is compatible with.
* @property {string} [browser_specific_settings.gecko.strict_max_version] - The maximum version of Firefox the extension is compatible with.
*/
export type BrowserSpecificSettings = {
browser_specific_settings?: {
gecko?: {
@@ -46,59 +77,164 @@ export type BrowserSpecificSettings = {
};
};
/**
* Represents the structure of a Chrome Manifest V3 file.
* This type is an alias for `ManifestV3Export` from the `@crxjs/vite-plugin`,
* which provides a comprehensive definition for Chrome extension manifests.
*/
export type Manifest = ManifestV3Export;
/** Alias for the `icons` property within a Chrome Manifest V3. */
export type ManifestIcons = chrome.runtime.ManifestIcons;
/** Alias for the `background` property within a Chrome Manifest V3. */
export type ManifestBackground = chrome.runtime.ManifestV3["background"];
/** Alias for the `content_scripts` property within a Chrome Manifest V3. */
export type ManifestContentScripts =
chrome.runtime.ManifestV3["content_scripts"];
/** Alias for the `web_accessible_resources` property within a Chrome Manifest V3. */
export type ManifestWebAccessibleResources =
chrome.runtime.ManifestV3["web_accessible_resources"];
/** Alias for the `commands` property within a Chrome Manifest V3. */
export type ManifestCommands = chrome.runtime.ManifestV3["commands"];
/** Alias for the `action` property (or `browser_action`/`page_action`) within a Chrome Manifest V3. */
export type ManifestAction = chrome.runtime.ManifestV3["action"];
/** Alias for the `permissions` property within a Chrome Manifest V3. */
export type ManifestPermissions = chrome.runtime.ManifestV3["permissions"];
/** Alias for the `options_ui` property within a Chrome Manifest V3. */
export type ManifestOptionsUI = chrome.runtime.ManifestV3["options_ui"];
/** Alias for the `chrome_url_overrides` property within a Chrome Manifest V3. */
export type ManifestURLOverrides =
chrome.runtime.ManifestV3["chrome_url_overrides"];
/**
* Creates a type that accepts a string literal `T` in either its capitalized or lowercase form.
* Useful for defining types that should be case-insensitive for specific known strings.
* @template T - A string literal type.
*/
export type BrowserName<T extends string> = Capitalize<T> | Lowercase<T>;
/**
* Creates a record type where both keys and values are derived from a string literal `T`,
* specifically using `BrowserName<T>` which allows for capitalized or lowercase forms.
* This could be used to define an object where, for example, keys are 'Chrome' or 'chrome'
* and values are also 'Chrome' or 'chrome'.
* @template T - A string literal type, typically representing a browser name.
*/
export type BrowserEnumType<T extends string> = {
[browser in BrowserName<T>]: BrowserName<T>;
};
/**
* Represents the target browser for a build, allowing for various casings of browser names
* (e.g., "chrome", "Chrome", "CHROME") through the `AnyCase<Browser>` utility type.
* `Browser` itself is a union of specific browser name strings (e.g., "Chrome" | "Firefox").
*/
export type BuildMode = AnyCase<Browser>;
/**
* Defines an object structure that pairs a web extension `Manifest`
* with its target `browser` (represented as `AnyCase<Browser>`).
* This is commonly used in build processes to manage configurations for different browsers.
*/
export type BuildTarget = {
manifest: Manifest;
browser: AnyCase<Browser>;
};
/**
* Defines the configuration options for a build process.
* @property {"build" | "serve"} [command] - The type of build command (e.g., 'build' for production, 'serve' for development).
* @property {AnyCase<Browser> | string | undefined} [mode] - The target build mode, typically a browser name (allowing various casings)
* or potentially other custom mode strings.
*/
export type BuildConfig = {
command?: "build" | "serve";
mode?: AnyCase<Browser> | string | undefined;
};
/**
* Defines the structure for repository information, commonly found in `package.json`.
* @property {string} type - The type of the repository (e.g., "git").
* @property {string} [url] - The URL of the repository.
* @property {Bugs} [bugs] - An object containing information about where to report bugs.
*/
export interface Repository {
type: string;
url?: string;
bugs?: Bugs;
}
/**
* Defines the structure for bug reporting information, often part of the `Repository` interface.
* @property {string} [url] - The URL of the issue tracker.
* @property {string} [email] - The email address for reporting bugs.
*/
export interface Bugs {
url?: string;
email?: string;
}
export type Browser = (typeof BrowserEnum)[keyof typeof BrowserEnum];
/**
* A string literal union type representing supported browser names, derived from the values of `BrowserEnum`.
* e.g., "Chrome" | "Firefox" | ...
*/
export type Browser = ObjectValues<typeof BrowserEnum>;
/**
* A constant intended to provide access to browser names, potentially in various casings.
* Its type `AnyCase<Browser>` suggests it can be used where case-insensitivity for browser names is needed.
* The `createEnum(BrowserEnum)` call aims to produce a representation of browser names from `BrowserEnum`.
* Note: `createEnum` from `lib/utils.ts` has a declared return type of `ObjectValues<T>` (a union of values),
* while its implementation uses `Object.values()` which returns an array. This constant will hold the
* runtime array value, but its JSDoc type refers to the more restrictive `AnyCase<Browser>` union type.
*/
export const Browser: AnyCase<Browser> = createEnum(BrowserEnum);
export type PackageManager =
(typeof PackageManagerEnum)[keyof typeof PackageManagerEnum];
/**
* A string literal union type representing supported package managers, derived from the values of `PackageManagerEnum`.
* e.g., "Bun" | "PnPm" | "Npm" | "Yarn"
*/
export type PackageManager = ObjectValues<typeof PackageManagerEnum>;
/**
* A constant intended to provide access to package manager names, potentially in various casings.
* Its type `AnyCase<PackageManager>` suggests it can be used where case-insensitivity for package manager names is needed.
* Utilizes `createEnum(PackageManagerEnum)`. Refer to notes on `Browser` constant regarding `createEnum` behavior.
*/
export const PackageManager: AnyCase<PackageManager> =
createEnum(PackageManagerEnum);
export type Framework = (typeof FrameworkEnum)[keyof typeof FrameworkEnum];
/**
* A string literal union type representing supported JavaScript frameworks, derived from the values of `FrameworkEnum`.
* e.g., "React" | "Vanilla" | ...
*/
export type Framework = ObjectValues<typeof FrameworkEnum>;
/**
* A constant intended to provide access to framework names, potentially in various casings.
* Its type `AnyCase<Framework>` suggests it can be used where case-insensitivity for framework names is needed.
* Utilizes `createEnum(FrameworkEnum)`. Refer to notes on `Browser` constant regarding `createEnum` behavior.
*/
export const Framework: AnyCase<Framework> = createEnum(FrameworkEnum);
export type Style = (typeof StyleEnum)[keyof typeof StyleEnum];
/**
* A string literal union type representing supported styling options, derived from the values of `StyleEnum`.
* e.g., "Tailwind"
*/
export type Style = ObjectValues<typeof StyleEnum>;
/**
* A constant intended to provide access to style option names, potentially in various casings.
* Its type `AnyCase<Style>` suggests it can be used where case-insensitivity for style names is needed.
* Utilizes `createEnum(StyleEnum)`. Refer to notes on `Browser` constant regarding `createEnum` behavior.
*/
export const Style: AnyCase<Style> = createEnum(StyleEnum);
export type Language = (typeof LanguageEnum)[keyof typeof LanguageEnum];
/**
* A string literal union type representing supported programming languages, derived from the values of `LanguageEnum`.
* e.g., "TypeScript" | "JavaScript"
*/
export type Language = ObjectValues<typeof LanguageEnum>;
/**
* A constant intended to provide access to programming language names, potentially in various casings.
* Its type `AnyCase<Language>` suggests it can be used where case-insensitivity for language names is needed.
* Utilizes `createEnum(LanguageEnum)`. Refer to notes on `Browser` constant regarding `createEnum` behavior.
*/
export const Language: AnyCase<Language> = createEnum(LanguageEnum);
+63
View File
@@ -1,21 +1,84 @@
/**
* Extracts a union type of all values from the properties of an object type `T`.
*
* @template T - An object type (typically a Record or an enum-like object).
* @example
* type MyObject = { a: "foo", b: "bar", c: 123 };
* type MyObjectValues = ObjectValues<MyObject>; // "foo" | "bar" | 123
*/
export type ObjectValues<T> = T[keyof T];
/**
* Creates a union of an object's string values, often used to represent the set of possible values for an enum-like object.
* Note: The implementation `Object.values(enumObj) as unknown as ObjectValues<T>` returns an array at runtime,
* but the declared return type `ObjectValues<T>` is a union of the object's property values.
* This type signature suggests it's intended to represent the set of possible string values from `enumObj`.
*
* @template T - An object type where keys are strings and values are strings (e.g., `const MyEnum = { VAL_A: "A", VAL_B: "B" }`).
* @param {T} enumObj - The object from which to extract values.
* @returns {ObjectValues<T>} A union type representing all possible string values of the `enumObj`.
* For example, if `enumObj` is `{ A: "valA", B: "valB" }`, the return type is `"valA" | "valB"`.
* (Runtime behavior of `Object.values()` is to return an array like `["valA", "valB"]`).
*/
export function createEnum<T extends Record<string, string>>(enumObj: T) {
return Object.values(enumObj) as unknown as ObjectValues<T>;
}
/**
* Creates a union type that includes various case formats (uppercase, lowercase, capitalized, uncapitalized)
* of a given string literal type `T`.
*
* @template T - A string literal type.
* @example
* type MyString = "example";
* type MyStringAnyCase = AnyCase<MyString>; // "EXAMPLE" | "example" | "Example" | "example" (Uncapitalize<"Example"> is "example")
*/
export type AnyCase<T extends string> =
| Uppercase<T>
| Lowercase<T>
| Capitalize<T>
| Uncapitalize<T>;
/**
* Creates a union type that includes various case formats (uppercase, lowercase, capitalized, uncapitalized)
* of the union of two given string literal types `T` and `K`.
* This is useful for representing a combined set of related string constants where case variations are permitted for each.
*
* @template T - A string literal type.
* @template K - Another string literal type.
* @example
* type Lang1 = "english";
* type Lang2 = "french";
* type CombinedLangsAnyCase = AnyCaseLanguage<Lang1, Lang2>;
* // Result includes: "ENGLISH" | "english" | "English" | "FRENCH" | "french" | "French" etc.
* // for all case variations of "english" and "french".
*/
export type AnyCaseLanguage<T extends string, K extends string> =
| Uppercase<T | K>
| Lowercase<T | K>
| Capitalize<T | K>
| Uncapitalize<T | K>;
/**
* Extracts a new object type containing only the keys of `T` whose properties are optional
* (i.e., their type includes `undefined`). The values associated with these keys retain their original types.
*
* @template T - An object type.
* @example
* type MyObject = {
* requiredProp: string;
* optionalProp?: number;
* anotherOptional?: boolean | undefined;
* nullProp: string | null;
* };
* type MyOptionalProps = OptionalKeys<MyObject>;
* // MyOptionalProps would be conceptually equivalent to:
* // {
* // optionalProp?: number;
* // anotherOptional?: boolean | undefined;
* // }
* // The actual resulting type is an object type with only these optional keys.
*/
export type OptionalKeys<T> = {
[K in keyof T as undefined extends T[K] ? K : never]: T[K];
};
+13
View File
@@ -24,6 +24,19 @@ if (document.childNodes[1]) {
init();
}
/**
* Initializes BetterSEQTA+ on a SEQTA page.
*
* This function performs the following steps:
* 1. Verifies that the current page is a SEQTA page.
* 2. Injects CSS styles for document loading.
* 3. Changes the page's favicon.
* 4. Initializes the extension's settings state.
* 5. Sets default storage if settings are not already defined.
* 6. Calls the main function to apply core BetterSEQTA+ modifications.
* 7. Initializes legacy and new plugins if the extension is enabled.
* 8. Logs success or error messages during initialization.
*/
async function init() {
const hasSEQTATitle = document.title.includes("SEQTA Learn");
+38
View File
@@ -1,5 +1,18 @@
import Parser from "rss-parser";
/**
* Fetches news articles specifically for Australia from the NewsAPI.
*
* This function handles a specific case for fetching Australian news. It includes a
* mechanism to retry the fetch operation by appending "%00" to the URL if a
* rate limit error (`response.code == "rateLimited"`) is encountered. This is
* likely a workaround for cache-busting or bypassing certain rate-limiting measures.
*
* @param {string} url The NewsAPI URL to fetch Australian news from.
* @param {any} sendResponse A callback function (likely from a browser extension message listener)
* to send the fetched news data back to the caller.
* It's called with an object like `{ news: responseData }`.
*/
const fetchAustraliaNews = async (url: string, sendResponse: any) => {
fetch(url)
.then((result) => result.json())
@@ -12,6 +25,12 @@ const fetchAustraliaNews = async (url: string, sendResponse: any) => {
});
};
/**
* A record mapping lowercase country codes (e.g., "usa", "canada") to an array
* of RSS feed URLs for news sources in that country.
*
* @type {Record<string, string[]>}
*/
const rssFeedsByCountry: Record<string, string[]> = {
usa: [
"https://rss.nytimes.com/services/xml/rss/nyt/HomePage.xml",
@@ -54,6 +73,25 @@ const rssFeedsByCountry: Record<string, string[]> = {
netherlands: ["https://www.dutchnews.nl/feed/", "https://www.nrc.nl/rss/"],
};
/**
* Fetches news articles based on a specified source.
*
* The source can be:
* 1. The string "australia": Fetches news from Australian sources via NewsAPI,
* handled by the `fetchAustraliaNews` function.
* 2. A lowercase country code (e.g., "usa", "canada"): Fetches news from a predefined
* list of RSS feeds for that country, as specified in `rssFeedsByCountry`.
* 3. A direct RSS feed URL (starting with "http"): Fetches news directly from this URL.
*
* The fetched articles are then sent back to the caller using the `sendResponse` callback.
*
* @param {string} source The news source identifier. This can be "australia", a
* lowercase country code, or a direct RSS feed URL.
* @param {any} sendResponse A callback function (typically from a browser extension
* message listener, like `chrome.runtime.onMessage`)
* used to send the fetched news data back to the caller.
* It's called with an object like `{ news: { articles: [...] } }`.
*/
export async function fetchNews(source: string, sendResponse: any) {
if (source === "australia") {
const date = new Date();
+76 -3
View File
@@ -1,8 +1,20 @@
import { type DBSchema, type IDBPDatabase, openDB } from "idb";
/**
* Defines the schema for the IndexedDB database used for storing background image data.
*
* @interface BackgroundDB
* @extends {DBSchema}
* @property {object} backgrounds - The object store for background images.
* @property {string} backgrounds.key - The type of the key for the object store (in this case, it's `id` as defined in `keyPath`).
* @property {object} backgrounds.value - The structure of the objects stored.
* @property {string} backgrounds.value.id - The unique identifier for the background image record.
* @property {string} backgrounds.value.type - The MIME type of the image (e.g., "image/png", "image/jpeg").
* @property {Blob} backgrounds.value.blob - The binary large object (Blob) containing the image data.
*/
interface BackgroundDB extends DBSchema {
backgrounds: {
key: string;
key: string; // Corresponds to the 'id' property due to keyPath: "id"
value: {
id: string;
type: string;
@@ -13,6 +25,14 @@ interface BackgroundDB extends DBSchema {
let db: IDBPDatabase<BackgroundDB> | null = null;
/**
* Initializes and opens an IndexedDB connection or returns an existing one.
* If the database doesn't exist or needs an upgrade, the `upgrade` callback
* creates the 'backgrounds' object store with 'id' as the keyPath.
*
* @async
* @returns {Promise<IDBPDatabase<BackgroundDB>>} A promise that resolves with the database instance.
*/
export async function openDatabase(): Promise<IDBPDatabase<BackgroundDB>> {
if (db) return db;
@@ -25,6 +45,12 @@ export async function openDatabase(): Promise<IDBPDatabase<BackgroundDB>> {
return db;
}
/**
* Retrieves all background image records from the 'backgrounds' object store in IndexedDB.
*
* @async
* @returns {Promise<Array<{id: string, type: string, blob: Blob}>>} A promise that resolves with an array of all background image records.
*/
export async function readAllData(): Promise<
Array<{ id: string; type: string; blob: Blob }>
> {
@@ -32,6 +58,16 @@ export async function readAllData(): Promise<
return db.getAll("backgrounds");
}
/**
* Writes or updates a background image record in the 'backgrounds' object store.
* If a record with the given `id` already exists, it will be updated. Otherwise, a new record is created.
*
* @async
* @param {string} id - The unique identifier for the background image record.
* @param {string} type - The MIME type of the image (e.g., "image/png").
* @param {Blob} blob - The Blob object containing the image data.
* @returns {Promise<void>} A promise that resolves when the data has been successfully written.
*/
export async function writeData(
id: string,
type: string,
@@ -41,16 +77,37 @@ export async function writeData(
await db.put("backgrounds", { id, type, blob });
}
/**
* Deletes a background image record from the 'backgrounds' object store by its ID.
*
* @async
* @param {string} id - The unique identifier of the background image record to delete.
* @returns {Promise<void>} A promise that resolves when the data has been successfully deleted.
*/
export async function deleteData(id: string): Promise<void> {
const db = await openDatabase();
await db.delete("backgrounds", id);
}
/**
* Clears all records from the 'backgrounds' object store in IndexedDB.
*
* @async
* @returns {Promise<void>} A promise that resolves when all data has been successfully cleared.
*/
export async function clearAllData(): Promise<void> {
const db = await openDatabase();
await db.clear("backgrounds");
}
/**
* Retrieves a single background image record from the 'backgrounds' object store by its ID.
*
* @async
* @param {string} id - The unique identifier of the background image record to retrieve.
* @returns {Promise<{id: string, type: string, blob: Blob} | undefined>} A promise that resolves with the
* background image record if found, or undefined otherwise.
*/
export async function getDataById(
id: string,
): Promise<{ id: string; type: string; blob: Blob } | undefined> {
@@ -58,6 +115,10 @@ export async function getDataById(
return db.get("backgrounds", id);
}
/**
* Closes the active IndexedDB connection and nullifies the global `db` variable.
* This is important to release resources and allow for proper database management.
*/
export function closeDatabase(): void {
if (db) {
db.close();
@@ -65,12 +126,24 @@ export function closeDatabase(): void {
}
}
// Helper function to check if IndexedDB is supported
/**
* Checks if IndexedDB is supported by the current browser environment.
*
* @returns {boolean} True if IndexedDB is supported, false otherwise.
*/
export function isIndexedDBSupported(): boolean {
return "indexedDB" in window;
}
// Helper function to check if there's enough storage space
/**
* Estimates available storage space and checks if it's sufficient for the specified `requiredSpace`.
* Uses the `navigator.storage.estimate()` API if available.
* If the API is not available or cannot determine space, it defaults to assuming enough space is available.
*
* @async
* @param {number} requiredSpace - The amount of storage space required, in bytes.
* @returns {Promise<boolean>} A promise that resolves with true if enough space is estimated to be available, false otherwise.
*/
export async function hasEnoughStorageSpace(
requiredSpace: number,
): Promise<boolean> {
+27
View File
@@ -1,11 +1,21 @@
type BackgroundUpdateCallback = () => void;
/**
* A singleton class used to notify listeners about generic background updates or events.
* These updates typically signify that UI components or other parts of the application
* might need to refresh or re-evaluate background-related data (e.g., after a new background
* image is added, removed, or changed).
*/
class BackgroundUpdates {
private static instance: BackgroundUpdates;
private listeners: Set<BackgroundUpdateCallback> = new Set();
private constructor() {}
/**
* Gets the singleton instance of the BackgroundUpdates class.
* @returns {BackgroundUpdates} The singleton instance.
*/
public static getInstance(): BackgroundUpdates {
if (!BackgroundUpdates.instance) {
BackgroundUpdates.instance = new BackgroundUpdates();
@@ -13,14 +23,31 @@ class BackgroundUpdates {
return BackgroundUpdates.instance;
}
/**
* Registers a callback function to be invoked when a background update is triggered.
*
* @param {BackgroundUpdateCallback} callback The function to call when a background update occurs.
* This callback takes no arguments and returns void.
*/
public addListener(callback: BackgroundUpdateCallback): void {
this.listeners.add(callback);
}
/**
* Unregisters a previously added callback function.
* After calling this method, the provided callback will no longer be invoked when a background update is triggered.
*
* @param {BackgroundUpdateCallback} callback The callback function to remove from the listeners.
*/
public removeListener(callback: BackgroundUpdateCallback): void {
this.listeners.delete(callback);
}
/**
* Invokes all registered listener callbacks, signifying that a background update has occurred.
* This method should be called whenever a change to background data happens that requires
* other parts of the application to be notified.
*/
public triggerUpdate(): void {
this.listeners.forEach((callback) => callback());
}
+16
View File
@@ -21,14 +21,30 @@ class SettingsPopup {
return SettingsPopup.instance;
}
/**
* Registers a callback function to be invoked when the settings popup is closed.
*
* @param {SettingsPopupCallback} callback The function to call when the settings popup closes.
* This callback takes no arguments and returns void.
*/
public addListener(callback: SettingsPopupCallback): void {
this.listeners.add(callback);
}
/**
* Unregisters a previously added callback function.
* After calling this method, the provided callback will no longer be invoked when the settings popup closes.
*
* @param {SettingsPopupCallback} callback The callback function to remove from the listeners.
*/
public removeListener(callback: SettingsPopupCallback): void {
this.listeners.delete(callback);
}
/**
* Invokes all registered listener callbacks.
* This method should be called when the settings popup is closed to notify all subscribed components or services.
*/
public triggerClose(): void {
this.listeners.forEach((callback) => callback());
}
+27
View File
@@ -1,11 +1,21 @@
type ThemeUpdateCallback = () => void;
/**
* A singleton class used to notify listeners about theme-related updates.
* These updates can include events like theme changes, custom theme modifications,
* or any other event that might require UI components to refresh their appearance
* or re-apply theme styles.
*/
class ThemeUpdates {
private static instance: ThemeUpdates;
private listeners: Set<ThemeUpdateCallback> = new Set();
private constructor() {}
/**
* Gets the singleton instance of the ThemeUpdates class.
* @returns {ThemeUpdates} The singleton instance.
*/
public static getInstance(): ThemeUpdates {
if (!ThemeUpdates.instance) {
ThemeUpdates.instance = new ThemeUpdates();
@@ -13,14 +23,31 @@ class ThemeUpdates {
return ThemeUpdates.instance;
}
/**
* Registers a callback function to be invoked when a theme update is triggered.
*
* @param {ThemeUpdateCallback} callback The function to call when a theme update occurs.
* This callback takes no arguments and returns void.
*/
public addListener(callback: ThemeUpdateCallback): void {
this.listeners.add(callback);
}
/**
* Unregisters a previously added callback function.
* After calling this method, the provided callback will no longer be invoked when a theme update is triggered.
*
* @param {ThemeUpdateCallback} callback The callback function to remove from the listeners.
*/
public removeListener(callback: ThemeUpdateCallback): void {
this.listeners.delete(callback);
}
/**
* Invokes all registered listener callbacks, signifying that a theme-related update has occurred.
* This method should be called whenever a change related to themes happens that requires
* other parts of the application to be notified.
*/
public triggerUpdate(): void {
this.listeners.forEach((callback) => callback());
}
+50
View File
@@ -1,9 +1,27 @@
import type { LoadedCustomTheme } from "@/types/CustomThemes";
/**
* Generates a random 9-character alphanumeric string to be used as a unique ID for images.
* This helps in identifying and managing custom images within a theme.
*
* @returns {string} A randomly generated unique ID string.
*/
export function generateImageId(): string {
return Math.random().toString(36).substr(2, 9);
}
/**
* Handles the upload of a new custom image from a file input event.
* If a file is selected, it reads the file using FileReader, converts it to a Blob,
* generates a unique ID and a default variable name for it, and then adds this new image
* to the `CustomImages` array within the provided `theme` object.
*
* @param {Event} event The file input change event, typically from an `<input type="file">` element.
* @param {LoadedCustomTheme} theme The current theme object to which the new image will be added.
* @returns {Promise<LoadedCustomTheme> | LoadedCustomTheme} A Promise that resolves with the updated theme object
* containing the new image if a file was processed.
* Returns the original theme object synchronously if no file was selected.
*/
export function handleImageUpload(
event: Event,
theme: LoadedCustomTheme,
@@ -34,6 +52,16 @@ export function handleImageUpload(
return theme;
}
/**
* Removes a custom image from the theme based on its ID.
* It filters out the image with the specified `imageId` from the `CustomImages` array
* in the `theme` object.
*
* @param {string} imageId The unique ID of the custom image to be removed.
* @param {LoadedCustomTheme} theme The current theme object from which the image will be removed.
* @returns {LoadedCustomTheme} A new theme object with the specified image removed from its `CustomImages` array.
* This function is synchronous.
*/
export function handleRemoveImage(
imageId: string,
theme: LoadedCustomTheme,
@@ -44,6 +72,17 @@ export function handleRemoveImage(
} as LoadedCustomTheme;
}
/**
* Updates the CSS variable name associated with a specific custom image in the theme.
* It finds the image by `imageId` in the `CustomImages` array of the `theme` object
* and updates its `variableName` property.
*
* @param {string} imageId The unique ID of the custom image whose variable name is to be updated.
* @param {string} variableName The new CSS variable name to assign to the image.
* @param {LoadedCustomTheme} theme The current theme object containing the image to be updated.
* @returns {LoadedCustomTheme} A new theme object with the updated image variable name.
* This function is synchronous.
*/
export function handleImageVariableChange(
imageId: string,
variableName: string,
@@ -57,6 +96,17 @@ export function handleImageVariableChange(
} as LoadedCustomTheme;
}
/**
* Handles the upload of a cover image for the theme from a file input event.
* If a file is selected, it reads the file using FileReader, converts it to a Blob,
* and then updates the `coverImage` property of the provided `theme` object with this new blob.
*
* @param {Event} event The file input change event, typically from an `<input type="file">` element.
* @param {LoadedCustomTheme} theme The current theme object whose cover image will be updated.
* @returns {Promise<LoadedCustomTheme>} A Promise that resolves with the updated theme object
* containing the new cover image if a file was processed.
* Returns a Promise resolving with the original theme object if no file was selected.
*/
export function handleCoverImageUpload(
event: Event,
theme: LoadedCustomTheme,
+60
View File
@@ -48,6 +48,40 @@ function createSEQTAAPI(): SEQTAAPI {
};
}
/**
* Creates a reactive and persistent settings store for a given plugin.
* This store is a Svelte-like store, providing reactivity, persistence
* via `browser.storage.local`, and default value handling.
*
* @template T - Represents the structure of the plugin's settings, extending `PluginSettings`.
* @param {Plugin<T, any>} plugin The plugin instance for which the settings store is being created.
* `plugin.id` is used for namespacing the settings in storage,
* and `plugin.settings` provides the definitions and default values for each setting.
* @returns {SettingsAPI<T> & { loaded: Promise<void> }} An object that functions as a Svelte store,
* enhanced with specific methods for settings management.
* The object includes:
* - Reactivity: Changes to settings can be subscribed to using Svelte's store subscription pattern
* (though not explicitly a Svelte store, it behaves similarly for direct property access and updates).
* The `onChange` method provides a more direct way to listen for specific key changes.
* - Persistence: Settings are automatically loaded from `browser.storage.local` when the store is created
* and saved back whenever a setting is changed via the proxy's setter.
* - Default Values: Uses default values from the `plugin.settings` definition if no stored value exists for a setting.
* - `loaded`: A Promise that resolves when the settings have been successfully loaded from storage,
* allowing operations to be deferred until settings are ready.
* - Direct property access for getting values (e.g., `settingsStore.mySettingKey`).
* - Direct property assignment for setting values (e.g., `settingsStore.mySettingKey = newValue`), which also persists the change.
* - `onChange(key, callback)`: Method to listen for changes to a specific setting. (Note: The prompt mentioned `listen`, this is `onChange`).
* Returns an object with an `unregister` method.
* - `offChange(key, callback)`: Method to stop listening for changes to a specific setting.
* The following methods are not explicitly present on the returned proxy from `createSettingsAPI` but are typically
* expected in a full "Svelte store" settings manager. The current implementation relies on direct property
* manipulation for get/set, and re-initialization for reset-like behavior or would require external implementation
* of reset logic if needed:
* - `get(key)`: (Achieved by direct property access: `settingsStore.key`)
* - `set(key, value)`: (Achieved by direct property assignment: `settingsStore.key = value`)
* - `reset(key)`: (Would require manual re-application of `plugin.settings[key].default` and then setting it)
* - `resetAll()`: (Would require iterating through all `plugin.settings` and applying defaults, then setting them)
*/
function createSettingsAPI<T extends PluginSettings>(
plugin: Plugin<T>,
): SettingsAPI<T> & { loaded: Promise<void> } {
@@ -293,6 +327,32 @@ function createEventsAPI(pluginId: string): EventsAPI {
};
}
/**
* Creates and returns a tailored API object for a specific plugin.
* This API object provides the plugin with various functionalities such as
* managing settings, accessing namespaced storage, interacting with SEQTA-specific features,
* and handling plugin-specific events.
*
* @template T - The type of the plugin's settings, extending `PluginSettings`.
* @template S - The type of the data the plugin will store in its namespaced storage.
* @param {Plugin<T, S>} plugin The plugin instance for which the API is being created.
* The plugin's `id` and `name` are used internally by the API
* for namespacing and identification but are accessed from the `plugin` object directly.
* @returns {PluginAPI<T, S>} An API object containing the following key properties:
* - `seqta`: An API for interacting with SEQTA-specific functionalities, created by `createSEQTAAPI()`.
* This includes methods like `onMount` for DOM element appearance, `getFiber` for React component inspection,
* `getCurrentPage` for getting the current SEQTA page, and `onPageChange` for listening to page navigations.
* - `settings`: An API for managing plugin-specific settings, created by `createSettingsAPI(plugin)`.
* It allows getting, setting, and listening to changes in the plugin's settings,
* which are stored persistently and namespaced to the plugin. Includes a `loaded` promise.
* - `storage`: An API for providing namespaced storage for the plugin, created by `createStorageAPI<S>(plugin.id)`.
* It allows the plugin to store and retrieve arbitrary data, namespaced to prevent conflicts
* with other plugins or parts of the extension. Includes a `loaded` promise and `onChange` listeners.
* - `events`: An API for allowing the plugin to dispatch and listen for custom events within its own scope,
* created by `createEventsAPI(plugin.id)`. It provides `on(event, callback)` to listen for
* plugin-specific events and `emit(event, ...args)` to dispatch them. These events are namespaced
* to the plugin.
*/
export function createPluginAPI<T extends PluginSettings, S = any>(
plugin: Plugin<T, S>,
): PluginAPI<T, S> {
+151 -3
View File
@@ -22,6 +22,12 @@ interface StorageChange<T = any> {
newValue?: T;
}
/**
* Singleton class responsible for the entire lifecycle of plugins.
* This includes registration, starting, stopping, event dispatching,
* managing plugin-specific styles, and listening for plugin setting changes
* to automatically start or stop plugins.
*/
export class PluginManager {
private static instance: PluginManager;
private plugins: Map<string, Plugin<any, any>> = new Map();
@@ -31,10 +37,18 @@ export class PluginManager {
private listeners: Map<string, Set<(...args: any[]) => void>> = new Map();
private styleElements: Map<string, HTMLStyleElement> = new Map();
/**
* Private constructor to enforce singleton pattern.
* Initializes the listener for plugin state changes from storage.
*/
private constructor() {
this.setupPluginStateListener();
}
/**
* Gets the singleton instance of the PluginManager.
* @returns {PluginManager} The singleton instance.
*/
public static getInstance(): PluginManager {
if (!PluginManager.instance) {
PluginManager.instance = new PluginManager();
@@ -42,6 +56,15 @@ export class PluginManager {
return PluginManager.instance;
}
/**
* Dispatches an event to a specific plugin.
* If the plugin is currently running, the event is dispatched immediately via a DOM CustomEvent.
* If the plugin is not running, the event is added to a backlog to be processed when the plugin starts.
*
* @param {string} pluginId The ID of the target plugin.
* @param {string} event The name of the event to dispatch (e.g., "update").
* @param {any} [args] Optional arguments to pass with the event.
*/
public dispatchPluginEvent(pluginId: string, event: string, args?: any) {
const fullEventName = `plugin.${pluginId}.${event}`;
@@ -57,6 +80,14 @@ export class PluginManager {
}
}
/**
* Processes and dispatches any events that were backlogged for a plugin.
* This is typically called after a plugin has successfully started.
*
* @private
* @param {string} pluginId The ID of the plugin for which to process backlogged events.
* @returns {Promise<void>}
*/
private async processBackloggedEvents(pluginId: string) {
for (const [key, argsList] of this.eventBacklog.entries()) {
const [eventPluginId, event] = key.split(":");
@@ -69,6 +100,15 @@ export class PluginManager {
}
}
/**
* Registers a plugin with the manager.
* Plugins must have a unique ID.
*
* @template T - The type of settings the plugin uses.
* @template S - The type of storage the plugin uses.
* @param {Plugin<T, S>} plugin The plugin object to register.
* @throws {Error} If a plugin with the same ID is already registered.
*/
public registerPlugin<T extends PluginSettings, S>(
plugin: Plugin<T, S>,
): void {
@@ -78,6 +118,22 @@ export class PluginManager {
this.plugins.set(plugin.id, plugin);
}
/**
* Starts a specific plugin by its ID.
* This involves:
* - Checking if the plugin exists and isn't already running.
* - Creating and providing the plugin API (settings, storage, etc.).
* - Checking if the plugin is enabled (if `disableToggle` is true), respecting its `defaultEnabled` status.
* - Injecting any CSS styles defined by the plugin into the document head.
* - Waiting for the plugin's settings and storage to be loaded.
* - Executing the plugin's `run` method.
* - Storing any cleanup function returned by `run` for later use in `stopPlugin`.
* - Marking the plugin as running and processing any backlogged events for it.
*
* @param {string} pluginId The ID of the plugin to start.
* @returns {Promise<void>} A promise that resolves when the plugin has started or is determined not to start (e.g., disabled).
* @throws {Error} If the plugin is not found, or if an error occurs during plugin initialization or execution.
*/
public async startPlugin(pluginId: string): Promise<void> {
const plugin = this.plugins.get(pluginId);
if (!plugin) {
@@ -139,17 +195,36 @@ export class PluginManager {
}
}
/**
* Attempts to start all registered plugins.
* Errors during the start of individual plugins are caught and logged,
* allowing other plugins to attempt to start.
*
* @returns {Promise<void>} A promise that resolves when all plugins have attempted to start.
* It uses `Promise.allSettled` to wait for all start operations.
*/
public async startAllPlugins(): Promise<void> {
const startPromises = Array.from(this.plugins.keys()).map((id) =>
this.startPlugin(id).catch((error) => {
console.error(`Failed to start plugin "${id}":`, error);
return Promise.reject(error);
return Promise.reject(error); // Still reject to indicate failure for this specific plugin if needed by caller
}),
);
await Promise.allSettled(startPromises);
}
/**
* Stops a specific plugin by its ID.
* This involves:
* - Removing any CSS styles injected by the plugin.
* - Executing the cleanup function that was returned by the plugin's `run` method (if any).
* - Marking the plugin as not running.
* - Emitting a "plugin.stopped" event with the pluginId.
*
* @param {string} pluginId The ID of the plugin to stop.
* @returns {Promise<void>} A promise that resolves when the plugin has been stopped.
*/
public async stopPlugin(pluginId: string): Promise<void> {
// Remove plugin styles
const styleElement = this.styleElements.get(pluginId);
@@ -168,18 +243,47 @@ export class PluginManager {
this.emit("plugin.stopped", pluginId);
}
/**
* Stops all currently running plugins.
* Iterates through all registered plugins and calls `stopPlugin` for each.
*/
public stopAllPlugins(): void {
Array.from(this.plugins.keys()).forEach((id) => this.stopPlugin(id));
}
/**
* Retrieves a registered plugin by its ID.
*
* @param {string} pluginId The ID of the plugin to retrieve.
* @returns {Plugin | undefined} The plugin object if found, otherwise undefined.
*/
public getPlugin(pluginId: string): Plugin | undefined {
return this.plugins.get(pluginId);
}
/**
* Retrieves an array of all registered plugin objects.
*
* @returns {Plugin[]} An array containing all registered plugin objects.
*/
public getAllPlugins(): Plugin[] {
return Array.from(this.plugins.values());
}
/**
* Retrieves a structured list of settings for all registered plugins.
* This is primarily used for building user interfaces for plugin configuration.
* It processes each plugin's defined settings, adding IDs, titles, descriptions,
* and default enabled states. For plugins with `disableToggle`, an "enabled"
* boolean setting is automatically included.
*
* @returns {Array<object>} An array of objects, where each object represents a plugin
* and contains its ID, name, description, beta status,
* and a processed `settings` object. The `settings` object
* maps setting keys to their detailed configuration (type, title, etc.).
* The specific structure of each setting object within `settings`
* depends on its type (boolean, string, number, select, button, hotkey).
*/
public getAllPluginSettings(): Array<{
pluginId: string;
name: string;
@@ -199,6 +303,8 @@ export class PluginManager {
| (Omit<HotkeySetting, "type"> & { type: "hotkey"; id: string })
| (Omit<ComponentSetting, "type"> & { type: "component"; id: string; component: any });
};
// Actual type is more complex, see original code, but this gives the gist for the JSDoc.
// Array<{ pluginId: string; name: string; description: string; beta?: boolean; settings: Record<string, ProcessedSetting>; disableToggle?: boolean; }>
}> {
return Array.from(this.plugins.entries()).map(([id, plugin]) => {
const settingsEntries = Object.entries(plugin.settings).map(
@@ -247,10 +353,24 @@ export class PluginManager {
});
}
/**
* Checks if a specific plugin is currently running.
*
* @param {string} pluginId The ID of the plugin to check.
* @returns {boolean} True if the plugin is running, false otherwise.
*/
public isPluginRunning(pluginId: string): boolean {
return this.runningPlugins.get(pluginId) || false;
}
/**
* Emits an event to all registered listeners for that event.
* This is an internal event bus for the PluginManager itself.
*
* @private
* @param {string} event The name of the event to emit.
* @param {any[]} args Arguments to pass to the event listeners.
*/
private emit(event: string, ...args: any[]): void {
const listeners = this.listeners.get(event);
if (listeners) {
@@ -258,6 +378,12 @@ export class PluginManager {
}
}
/**
* Registers an event listener for PluginManager's internal events.
*
* @param {string} event The name of the event to listen for (e.g., "plugin.stopped").
* @param {(...args: any[]) => void} callback The function to call when the event is emitted.
*/
public on(event: string, callback: (...args: any[]) => void): void {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
@@ -265,6 +391,12 @@ export class PluginManager {
this.listeners.get(event)!.add(callback);
}
/**
* Unregisters an event listener for PluginManager's internal events.
*
* @param {string} event The name of the event.
* @param {(...args: any[]) => void} callback The callback function to remove.
*/
public off(event: string, callback: (...args: any[]) => void): void {
const listeners = this.listeners.get(event);
if (listeners) {
@@ -272,7 +404,16 @@ export class PluginManager {
}
}
// Add handler for plugin enable/disable state changes
/**
* Handles the change in a plugin's enabled state.
* Starts or stops the plugin based on the new `enabled` value.
* This is typically called by `setupPluginStateListener` when a relevant storage change is detected.
*
* @private
* @param {string} pluginId The ID of the plugin whose state has changed.
* @param {boolean} enabled The new enabled state of the plugin.
* @returns {Promise<void>}
*/
private async handlePluginStateChange(
pluginId: string,
enabled: boolean,
@@ -284,7 +425,14 @@ export class PluginManager {
}
}
// Add listener for plugin settings changes
/**
* Sets up a listener for browser storage changes.
* This listener monitors changes to plugin settings (specifically the `enabled` property
* for plugins with `disableToggle: true`) and calls `handlePluginStateChange`
* to automatically start or stop plugins accordingly.
*
* @private
*/
private setupPluginStateListener(): void {
browser.storage.onChanged.addListener(
(changes: { [key: string]: StorageChange }, area: string) => {
+99 -4
View File
@@ -5,9 +5,20 @@ import type {
SelectSetting,
StringSetting,
HotkeySetting,
PluginSettings,
ComponentSetting,
} from "./types";
/**
* Creates a complete `NumberSetting` object from its options.
* This helper function ensures the `type` property is correctly set to "number".
* It's used for defining a numeric setting for a plugin.
* This function itself does not handle storage or persistence; it defines the setting's structure.
*
* @param {Omit<NumberSetting, "type">} options The configuration options for the number setting,
* excluding the `type` property (e.g., `title`, `default`, `min`, `max`).
* @returns {NumberSetting} A complete number setting object with `type: "number"`.
*/
export function numberSetting(
options: Omit<NumberSetting, "type">,
): NumberSetting {
@@ -17,6 +28,16 @@ export function numberSetting(
};
}
/**
* Creates a complete `BooleanSetting` object from its options.
* This helper function ensures the `type` property is correctly set to "boolean".
* It's used for defining a boolean (true/false) setting for a plugin.
* This function itself does not handle storage or persistence; it defines the setting's structure.
*
* @param {Omit<BooleanSetting, "type">} options The configuration options for the boolean setting,
* excluding the `type` property (e.g., `title`, `default`).
* @returns {BooleanSetting} A complete boolean setting object with `type: "boolean"`.
*/
export function booleanSetting(
options: Omit<BooleanSetting, "type">,
): BooleanSetting {
@@ -26,6 +47,16 @@ export function booleanSetting(
};
}
/**
* Creates a complete `StringSetting` object from its options.
* This helper function ensures the `type` property is correctly set to "string".
* It's used for defining a text-based setting for a plugin.
* This function itself does not handle storage or persistence; it defines the setting's structure.
*
* @param {Omit<StringSetting, "type">} options The configuration options for the string setting,
* excluding the `type` property (e.g., `title`, `default`, `placeholder`).
* @returns {StringSetting} A complete string setting object with `type: "string"`.
*/
export function stringSetting(
options: Omit<StringSetting, "type">,
): StringSetting {
@@ -35,15 +66,36 @@ export function stringSetting(
};
}
export function selectSetting<T extends string>(
options: Omit<SelectSetting<T>, "type">,
): SelectSetting<T> {
/**
* Creates a complete `SelectSetting` object from its options.
* This helper function ensures the `type` property is correctly set to "select".
* It's used for defining a setting where the user can choose from a predefined list of options.
* This function itself does not handle storage or persistence; it defines the setting's structure.
*
* @template TValue - The type of the value for each option in the select list (extends string).
* @param {Omit<SelectSetting<TValue>, "type">} options The configuration options for the select setting,
* excluding the `type` property (e.g., `title`, `default`, `options` array).
* @returns {SelectSetting<TValue>} A complete select setting object with `type: "select"`.
*/
export function selectSetting<TValue extends string>(
options: Omit<SelectSetting<TValue>, "type">,
): SelectSetting<TValue> {
return {
type: "select",
...options,
};
}
/**
* Creates a complete `ButtonSetting` object from its options.
* This helper function ensures the `type` property is correctly set to "button".
* It's used for defining a button in the plugin's settings UI, which can trigger an action.
* This function itself does not handle storage or persistence; it defines the button's structure and action.
*
* @param {Omit<ButtonSetting, "type">} options The configuration options for the button setting,
* excluding the `type` property (e.g., `title`, `label`, `trigger` function).
* @returns {ButtonSetting} A complete button setting object with `type: "button"`.
*/
export function buttonSetting(
options: Omit<ButtonSetting, "type">,
): ButtonSetting {
@@ -53,6 +105,17 @@ export function buttonSetting(
};
}
/**
* Creates a complete `HotkeySetting` object from its options.
* This helper function ensures the `type` property is correctly set to "hotkey".
* It's used for defining a setting where the user can configure a keyboard shortcut.
* This function itself does not handle storage or persistence; it defines the hotkey setting's structure.
*
* @param {Omit<HotkeySetting, "type">} options The configuration options for the hotkey setting,
* excluding the `type` property (e.g., `title`, `default` hotkey string).
* @returns {HotkeySetting} A complete hotkey setting object with `type: "hotkey"`.
*/
export function componentSetting(
options: Omit<ComponentSetting, "type">,
): ComponentSetting {
@@ -71,10 +134,42 @@ export function hotkeySetting(
};
}
export function defineSettings<T extends Record<string, any>>(settings: T): T {
/**
* Defines a collection of settings for a plugin.
* This function currently acts as an identity function, returning the settings object as is.
* Its primary purpose is to provide type inference and a structured way to define
* the entire settings configuration for a plugin, ensuring it conforms to the expected type.
* This function itself does not handle storage or persistence; it's for structural definition.
*
* @template TSettings - A record type where keys are setting names and values are setting definition objects
* (e.g., `NumberSetting`, `BooleanSetting`).
* @param {TSettings} settings The complete settings configuration object for the plugin.
* @returns {TSettings} The same settings configuration object, primarily for type checking/inference.
*/
export function defineSettings<TSettings extends Record<string, any>>(settings: TSettings): TSettings {
return settings;
}
/**
* A property decorator for declaratively defining a plugin setting on a class property.
* When a class property is decorated with `@Setting({...})`, this decorator adds the
* provided setting definition (`settingDef`) to a static `settings` object on the
* class's prototype. This allows settings to be defined alongside their related class logic.
* This decorator itself does not handle runtime storage or persistence of setting *values*;
* it is for defining the *structure* and *metadata* of a setting.
*
* Example:
* ```typescript
* class MyPlugin extends BasePlugin {
* @Setting(numberSetting({ title: "My Number", default: 10 }))
* myNumberSetting: number; // Type annotation for the setting's value
* }
* ```
*
* @param {any} settingDef The setting definition object, typically created by one of the
* helper functions like `numberSetting(...)`, `booleanSetting(...)`, etc.
* @returns {PropertyDecorator} A property decorator function.
*/
export function Setting(settingDef: any): PropertyDecorator {
return (target, propertyKey) => {
const proto = target.constructor.prototype;
+9
View File
@@ -1,3 +1,12 @@
/**
* Pauses execution for a specified number of milliseconds.
*
* This function returns a Promise that resolves after the given delay,
* allowing it to be used with `async/await` to pause asynchronous operations.
*
* @param {number} ms The number of milliseconds to delay.
* @returns {Promise<void>} A Promise that resolves after the specified delay.
*/
export function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
+44 -5
View File
@@ -1,12 +1,51 @@
// Simple mutex implementation
/**
* @callback UnlockFunction
* @description A function that must be called to release the mutex.
* @returns {void}
*/
/**
* A simple mutex implementation for managing asynchronous operations.
* It ensures that only one operation can hold the lock at a time.
* Operations queue up and are granted access sequentially.
*/
export class Mutex {
private mutex = Promise.resolve();
lock(): PromiseLike<() => void> {
let begin: (unlock: () => void) => void;
/**
* Acquires the mutex.
*
* This method returns a Promise that resolves with an {@link UnlockFunction}.
* The calling code *must* call this {@link UnlockFunction} to release the mutex
* once the critical section of code has completed.
*
* If the mutex is already locked, this method will wait until it is released
* before resolving the Promise.
*
* @returns {Promise<UnlockFunction>} A Promise that resolves with the function to call to release the lock.
*/
acquire(): Promise<() => void> {
let begin: (unlock: () => void) => void = () => {}; // Initialize with a no-op
this.mutex = this.mutex.then(() => new Promise(begin));
const newPromise = new Promise<void>((resolve) => {
begin = resolve;
});
return new Promise((res) => (begin = res));
const chainedPromise = this.mutex.then(() => {
return new Promise<() => void>((resolveOuter) => {
// The 'begin' function, when called, will resolve the newPromise,
// effectively passing control to the next then() in the chain.
// We pass 'begin' itself as the unlock function.
// So, when the user calls unlock (which is 'begin'), newPromise resolves.
resolveOuter(begin);
});
});
this.mutex = newPromise;
return chainedPromise;
}
// Note: There isn't a separate `release()` method in this pattern.
// The lock is released by calling the function returned by `acquire()`.
}
+12
View File
@@ -1,5 +1,17 @@
import DOMPurify from "dompurify";
/**
* Converts an HTML string into a DOM element, with sanitization and optional styling.
*
* This function first sanitizes the input HTML string using DOMPurify to prevent XSS attacks.
* The sanitization process allows 'onclick' attributes and specific URI schemes.
* Then, it parses the sanitized string into an HTML document and returns its body.
* Optionally, it can apply predefined CSS styles to the body element.
*
* @param {string} str The HTML string to convert.
* @param {boolean} [styles=false] Whether to apply predefined styles to the document body.
* @returns {HTMLElement} The body element of the parsed and sanitized HTML document.
*/
export default function stringToHTML(str: string, styles = false) {
const parser = new DOMParser();
+14
View File
@@ -1,6 +1,20 @@
import { eventManager } from "@/seqta/utils/listeners/EventManager";
import { delay } from "@/seqta/utils/delay";
/**
* Asynchronously waits for an element to be present in the DOM.
*
* This function can use either a polling mechanism (via `setTimeout`) or
* a `MutationObserver` (via `eventManager.register`) to detect the element.
* By default, it uses the `eventManager` which is more efficient.
*
* @param {string} selector The CSS selector for the target element.
* @param {boolean} [usePolling=false] If true, forces the use of `setTimeout` for polling.
* @param {number} [interval=100] The polling interval in milliseconds (only applicable if `usePolling` is true).
* @param {number} [maxIterations] Optional. The maximum number of polling attempts before rejecting (only applicable if `usePolling` is true).
* @returns {Promise<Element>} A Promise that resolves with the found DOM Element.
* If `usePolling` is true and `maxIterations` is reached, the Promise rejects with an Error.
*/
export async function waitForElm(
selector: string,
usePolling: boolean = false,