Browse Source

feat: plugin registration stuff

Sv443 1 year ago
parent
commit
44fa2344a1
7 changed files with 284 additions and 24 deletions
  1. 3 1
      assets/README.md
  2. 23 16
      assets/translations/README.md
  3. 3 1
      assets/translations/en_US.json
  4. 91 1
      contributing.md
  5. 1 1
      src/dialogs/dialogs.css
  6. 81 2
      src/interface.ts
  7. 82 2
      src/types.ts

+ 3 - 1
assets/README.md

@@ -31,7 +31,9 @@ The keys of the object are the locale codes, and the values are the locale objec
 <br>
 
 ### [`plugins.json`](plugins.json)
-(Not implemented yet)
+(Not fully implemented yet, but should still be filled out when a plugin is added)  
+  
+For the structure of this array of objects, see `type PluginObj` in [`src/types.ts`](../src/types.ts)
 
 <br>
 

+ 23 - 16
assets/translations/README.md

@@ -20,15 +20,15 @@ To submit or edit a translation, please follow [this guide](../../contributing.m
 ### Translation progress:
 | &nbsp; | Locale | Translated keys | Based on |
 | :----: | ------ | --------------- | :------: |
-| ─ | [`en_US`](./en_US.json) | 180 (default locale) |  |
-| ‼️ | [`de_DE`](./de_DE.json) | `162/180` (90%) | ─ |
-| ─ | [`en_UK`](./en_UK.json) | `180/180` (100%) | `en_US` |
-| ‼️ | [`es_ES`](./es_ES.json) | `162/180` (90%) | ─ |
-| ‼️ | [`fr_FR`](./fr_FR.json) | `162/180` (90%) | ─ |
-| ‼️ | [`hi_IN`](./hi_IN.json) | `162/180` (90%) | ─ |
-| ‼️ | [`ja_JA`](./ja_JA.json) | `162/180` (90%) | ─ |
-| ‼️ | [`pt_BR`](./pt_BR.json) | `162/180` (90%) | ─ |
-| ‼️ | [`zh_CN`](./zh_CN.json) | `162/180` (90%) | ─ |
+| ─ | [`en_US`](./en_US.json) | 181 (default locale) |  |
+| ‼️ | [`de_DE`](./de_DE.json) | `162/181` (89.5%) | ─ |
+| ─ | [`en_UK`](./en_UK.json) | `181/181` (100%) | `en_US` |
+| ‼️ | [`es_ES`](./es_ES.json) | `162/181` (89.5%) | ─ |
+| ‼️ | [`fr_FR`](./fr_FR.json) | `162/181` (89.5%) | ─ |
+| ‼️ | [`hi_IN`](./hi_IN.json) | `162/181` (89.5%) | ─ |
+| ‼️ | [`ja_JA`](./ja_JA.json) | `162/181` (89.5%) | ─ |
+| ‼️ | [`pt_BR`](./pt_BR.json) | `162/181` (89.5%) | ─ |
+| ‼️ | [`zh_CN`](./zh_CN.json) | `162/181` (89.5%) | ─ |
 
 <sub>
 ✅ - Fully translated
@@ -49,7 +49,7 @@ This means to figure out which keys are untranslated, you will need to manually
 
 ### Missing keys:
 
-<details><summary><code>de_DE</code> - 18 missing keys <i>(click to show)</i></summary><br>
+<details><summary><code>de_DE</code> - 19 missing keys <i>(click to show)</i></summary><br>
 
 | Key | English text |
 | --- | ------------ |
@@ -71,10 +71,11 @@ This means to figure out which keys are untranslated, you will need to manually
 | `feature_desc_rememberSongTimeReduction` | `How many seconds to subtract when restoring the time of a remembered song` |
 | `feature_helptext_rememberSongTimeReduction` | `When restoring the time of a song that was remembered, this amount of seconds will be subtracted from the remembered time so you can re-listen to the part that was interrupted.` |
 | `feature_desc_rememberSongTimeMinPlayTime` | `Minimum amount of seconds a song needs to be played for its time to be remembered` |
+| `plugin_validation_error_no_property` | `No property '%1' with type '%2'` |
 
 <br></details>
 
-<details><summary><code>es_ES</code> - 18 missing keys <i>(click to show)</i></summary><br>
+<details><summary><code>es_ES</code> - 19 missing keys <i>(click to show)</i></summary><br>
 
 | Key | English text |
 | --- | ------------ |
@@ -96,10 +97,11 @@ This means to figure out which keys are untranslated, you will need to manually
 | `feature_desc_rememberSongTimeReduction` | `How many seconds to subtract when restoring the time of a remembered song` |
 | `feature_helptext_rememberSongTimeReduction` | `When restoring the time of a song that was remembered, this amount of seconds will be subtracted from the remembered time so you can re-listen to the part that was interrupted.` |
 | `feature_desc_rememberSongTimeMinPlayTime` | `Minimum amount of seconds a song needs to be played for its time to be remembered` |
+| `plugin_validation_error_no_property` | `No property '%1' with type '%2'` |
 
 <br></details>
 
-<details><summary><code>fr_FR</code> - 18 missing keys <i>(click to show)</i></summary><br>
+<details><summary><code>fr_FR</code> - 19 missing keys <i>(click to show)</i></summary><br>
 
 | Key | English text |
 | --- | ------------ |
@@ -121,10 +123,11 @@ This means to figure out which keys are untranslated, you will need to manually
 | `feature_desc_rememberSongTimeReduction` | `How many seconds to subtract when restoring the time of a remembered song` |
 | `feature_helptext_rememberSongTimeReduction` | `When restoring the time of a song that was remembered, this amount of seconds will be subtracted from the remembered time so you can re-listen to the part that was interrupted.` |
 | `feature_desc_rememberSongTimeMinPlayTime` | `Minimum amount of seconds a song needs to be played for its time to be remembered` |
+| `plugin_validation_error_no_property` | `No property '%1' with type '%2'` |
 
 <br></details>
 
-<details><summary><code>hi_IN</code> - 18 missing keys <i>(click to show)</i></summary><br>
+<details><summary><code>hi_IN</code> - 19 missing keys <i>(click to show)</i></summary><br>
 
 | Key | English text |
 | --- | ------------ |
@@ -146,10 +149,11 @@ This means to figure out which keys are untranslated, you will need to manually
 | `feature_desc_rememberSongTimeReduction` | `How many seconds to subtract when restoring the time of a remembered song` |
 | `feature_helptext_rememberSongTimeReduction` | `When restoring the time of a song that was remembered, this amount of seconds will be subtracted from the remembered time so you can re-listen to the part that was interrupted.` |
 | `feature_desc_rememberSongTimeMinPlayTime` | `Minimum amount of seconds a song needs to be played for its time to be remembered` |
+| `plugin_validation_error_no_property` | `No property '%1' with type '%2'` |
 
 <br></details>
 
-<details><summary><code>ja_JA</code> - 18 missing keys <i>(click to show)</i></summary><br>
+<details><summary><code>ja_JA</code> - 19 missing keys <i>(click to show)</i></summary><br>
 
 | Key | English text |
 | --- | ------------ |
@@ -171,10 +175,11 @@ This means to figure out which keys are untranslated, you will need to manually
 | `feature_desc_rememberSongTimeReduction` | `How many seconds to subtract when restoring the time of a remembered song` |
 | `feature_helptext_rememberSongTimeReduction` | `When restoring the time of a song that was remembered, this amount of seconds will be subtracted from the remembered time so you can re-listen to the part that was interrupted.` |
 | `feature_desc_rememberSongTimeMinPlayTime` | `Minimum amount of seconds a song needs to be played for its time to be remembered` |
+| `plugin_validation_error_no_property` | `No property '%1' with type '%2'` |
 
 <br></details>
 
-<details><summary><code>pt_BR</code> - 18 missing keys <i>(click to show)</i></summary><br>
+<details><summary><code>pt_BR</code> - 19 missing keys <i>(click to show)</i></summary><br>
 
 | Key | English text |
 | --- | ------------ |
@@ -196,10 +201,11 @@ This means to figure out which keys are untranslated, you will need to manually
 | `feature_desc_rememberSongTimeReduction` | `How many seconds to subtract when restoring the time of a remembered song` |
 | `feature_helptext_rememberSongTimeReduction` | `When restoring the time of a song that was remembered, this amount of seconds will be subtracted from the remembered time so you can re-listen to the part that was interrupted.` |
 | `feature_desc_rememberSongTimeMinPlayTime` | `Minimum amount of seconds a song needs to be played for its time to be remembered` |
+| `plugin_validation_error_no_property` | `No property '%1' with type '%2'` |
 
 <br></details>
 
-<details><summary><code>zh_CN</code> - 18 missing keys <i>(click to show)</i></summary><br>
+<details><summary><code>zh_CN</code> - 19 missing keys <i>(click to show)</i></summary><br>
 
 | Key | English text |
 | --- | ------------ |
@@ -221,5 +227,6 @@ This means to figure out which keys are untranslated, you will need to manually
 | `feature_desc_rememberSongTimeReduction` | `How many seconds to subtract when restoring the time of a remembered song` |
 | `feature_helptext_rememberSongTimeReduction` | `When restoring the time of a song that was remembered, this amount of seconds will be subtracted from the remembered time so you can re-listen to the part that was interrupted.` |
 | `feature_desc_rememberSongTimeMinPlayTime` | `Minimum amount of seconds a song needs to be played for its time to be remembered` |
+| `plugin_validation_error_no_property` | `No property '%1' with type '%2'` |
 
 <br></details>

+ 3 - 1
assets/translations/en_US.json

@@ -195,6 +195,8 @@
     "feature_desc_logLevel": "How much information to log to the console",
     "feature_helptext_logLevel": "Changing this is really only needed for debugging purposes as a result of experiencing a problem.\nShould you have one, you can increase the log level here, open your browser's JavaScript console (usually with Ctrl + Shift + K) and attach screenshots of that log in a GitHub issue.",
     "feature_desc_advancedMode": "Show advanced settings (after reload)",
-    "feature_helptext_advancedMode": "Show advanced settings in the configuration menu after reloading the page.\nThis is useful if you want to more deeply customize the script's behavior."
+    "feature_helptext_advancedMode": "Show advanced settings in the configuration menu after reloading the page.\nThis is useful if you want to more deeply customize the script's behavior.",
+
+    "plugin_validation_error_no_property": "No property '%1' with type '%2'"
   }
 }

+ 91 - 1
contributing.md

@@ -153,7 +153,7 @@ These are the ways to interact with BetterYTM; constants, events and global func
 - Another way of dynamically interacting is through global functions, which are also exposed by BetterYTM through the global `BYTM` object.  
   You can find all functions that are available in the `InterfaceFunctions` type in [`src/types.ts`](src/types.ts)  
   There is also a summary with examples [below.](#global-functions)  
-  Additionally to those functions, the namespace `BYTM.UserUtils` is also exposed, which contains all functions from the [UserUtils](https://github.com/Sv443-Network/UserUtils) library.
+  Additionally to those functions, the namespace `BYTM.UserUtils` is also exposed, which contains all exported members from the [UserUtils library.](https://github.com/Sv443-Network/UserUtils)
 
 All of these interactions require the use of `unsafeWindow`, as the regular window object is pretty sandboxed in userscript managers.  
   
@@ -256,6 +256,9 @@ An easy way to do this might be to include BetterYTM as a Git submodule, as long
 These are the global functions that are exposed by BetterYTM through the `unsafeWindow.BYTM` object.  
 The usage and example blocks on each are written in TypeScript but can be used in JavaScript as well, after removing all type annotations.  
   
+- Meta:
+  - [registerPlugin()](#registerplugin) - Registers a plugin with BetterYTM with the given plugin definition object
+  - [getPluginInfo()](#getplugininfo) - Returns the plugin info object for the specified plugin - also used to check if a certain plugin is registered
 - BYTM-specific:
   - [getResourceUrl()](#getresourceurl) - Returns a `blob:` URL provided by the local userscript extension for the specified BYTM resource file
   - [getSessionId()](#getsessionid) - Returns the unique session ID that is generated on every started session
@@ -283,6 +286,93 @@ The usage and example blocks on each are written in TypeScript but can be used i
 - Other:
   - [NanoEmitter](#nanoemitte) - Abstract class for creating lightweight, type safe event emitting classes
 
+<br><br>
+
+> #### registerPlugin()
+> Usage:
+> ```ts
+> unsafeWindow.BYTM.registerPlugin(pluginDef: PluginDef): PluginRegisterResult
+> ```
+>   
+> Description:  
+> Registers a plugin with BetterYTM with the given plugin definition object.  
+>   
+> Arguments:  
+> - `pluginDef` - The properties of this plugin definition object can be found by searching for `type PluginDef` in the file [`src/types.ts`](./src/types.ts)  
+>   
+> The function will either throw an error if the plugin object is invalid or return a registration result object.  
+> Its type can be found by searching for `type PluginRegisterResult` in the file [`src/types.ts`](./src/types.ts)
+> 
+> <details><summary><b>Example <i>(click to expand)</i></b></summary>
+> 
+> ```ts
+> const pluginDef = {
+>   plugin: {
+>     name: "My cool plugin",
+>     namespace: "https://github.com/MyUsername",
+>     version: [1, 0, 0],
+>     description: {
+>       en_US: "This is a plugin that does cool stuff",
+>       de_DE: "Dies ist ein Plugin, das coole Sachen macht",
+>     },
+>     iconUrl: "https://picsum.photos/128/128",
+>     homepage: {
+>       github: "https://github.com/MyUsername/MyCoolBYTMPlugin",
+>       greasyfork: "...",
+>       openuserjs: "...",
+>     },
+>   },
+>   contributors: [
+>     {
+>       name: "MyUsername",
+>       homepage: "https://github.com/MyUsername",
+>       email: "[email protected]",
+>     },
+>   ],
+> };
+> 
+> unsafeWindow.addEventListener("bytm:ready", () => {
+>   unsafeWindow.BYTM.registerPlugin(pluginDef);
+> });
+> ```
+> </details>
+
+<br>
+
+> #### getPluginInfo()
+> Usage:  
+> ```ts
+> unsafeWindow.BYTM.getPluginInfo(name: string, namespace: string): PluginInfo | undefined
+> unsafeWindow.BYTM.getPluginInfo(pluginDef: { plugin: { name: string, namespace: string } }): PluginInfo | undefined
+> ```
+>   
+> Description:  
+> Returns the plugin info object for the specified plugin. It's basically a more restricted version of the plugin definition object.  
+> This object contains all information that other plugins will be able to see about your plugin.  
+>   
+> Arguments:
+> - `name` - The 'name' property of the plugin
+> - `namespace` - The 'namespace' property of the plugin
+> OR:
+> - `pluginDef` - A plugin definition object containing at least the `plugin.name` and `plugin.namespace` properties
+>   
+> The function will return `undefined` if the plugin is not registered.  
+> The type of the returned object can be found by searching for `type PluginInfo` in the file [`src/types.ts`](./src/types.ts)
+> 
+> <details><summary><b>Example <i>(click to expand)</i></b></summary>
+> 
+> ```ts
+> unsafeWindow.addEventListener("bytm:pluginsLoaded", () => {
+>   const pluginInfo = unsafeWindow.BYTM.getPluginInfo("My cool plugin", "https://github.com/MyUsername");
+>   if(pluginInfo) {
+>     console.log(`The plugin '${pluginInfo.name}' with version '${pluginInfo.version.join(".")}' is loaded`);
+>   }
+>   else
+>     console.error("The plugin 'My cool plugin' is not registered");
+> });
+> ```
+> </details>
+
 <br>
 
 > #### getResourceUrl()

+ 1 - 1
src/dialogs/dialogs.css

@@ -2,7 +2,7 @@
   --bytm-dialog-accent-col: #3683d4;
   --bytm-advanced-mode-color: #c5a73b;
   --bytm-experimental-col: #d07ff0;
-  --bytm-warning-col: #f27735;
+  --bytm-warning-col: #ff5233;
 }
 
 /* TODO(v1.2): leave only dialog */

+ 81 - 2
src/interface.ts

@@ -1,17 +1,21 @@
 import * as UserUtils from "@sv443-network/userutils";
 import { mode, branch, host, buildNumber, compressionFormat, scriptInfo } from "./constants";
-import { getResourceUrl, getSessionId, getVideoTime, log, setLocale, getLocale, hasKey, hasKeyFor, NanoEmitter, t, tp, type TrLocale } from "./utils";
+import { getResourceUrl, getSessionId, getVideoTime, log, setLocale, getLocale, hasKey, hasKeyFor, NanoEmitter, t, tp, type TrLocale, info, error } from "./utils";
 import { addSelectorListener } from "./observers";
 import { getFeatures, saveFeatures } from "./config";
 import { featInfo, fetchLyricsUrlTop, getLyricsCacheEntry, sanitizeArtists, sanitizeSong, type LyricsCache } from "./features";
 import type { SiteEventsMap } from "./siteEvents";
-import type { FeatureConfig, FeatureInfo, LyricsCacheEntry } from "./types";
+import { LogLevel, type FeatureConfig, type FeatureInfo, type LyricsCacheEntry, type PluginDef, type PluginInfo, type PluginRegisterResult, type PluginDefResolvable } from "./types";
 import { BytmDialog, createHotkeyInput, createToggleInput } from "./components";
 
 const { getUnsafeWindow } = UserUtils;
 
 /** All events that can be emitted on the BYTM interface and the data they provide */
 export type InterfaceEvents = {
+  /** Emitted whenever the plugins should be registered using `unsafeWindow.BYTM.registerPlugin()` */
+  "bytm:initPlugins": undefined;
+  /** Emitted whenever all plugins have been loaded */
+  "bytm:pluginsLoaded": undefined;
   /** Emitted when BYTM has finished initializing all features */
   "bytm:ready": undefined;
   /**
@@ -44,6 +48,11 @@ export type InterfaceEvents = {
 };
 
 const globalFuncs = {
+  // meta
+  registerPlugin,
+  getPluginInfo,
+
+  // utils
   addSelectorListener,
   getResourceUrl,
   getSessionId,
@@ -62,6 +71,8 @@ const globalFuncs = {
   sanitizeSong,
 };
 
+const plugins = new Map<string, PluginDef>();
+
 /** Initializes the BYTM interface */
 export function initInterface() {
   const props = {
@@ -113,6 +124,74 @@ export function emitInterface<
   getUnsafeWindow().dispatchEvent(new CustomEvent(type, { detail: data[0] }));
 }
 
+//#MARKER register plugins
+
+export function loadPlugins() {
+  // TODO:
+  // load plugins between bytm:initPlugins and bytm:ready
+  // emit bytm:pluginsLoaded after all plugins loaded
+}
+
+/** Returns the key for a given plugin definition */
+function getPluginKey(plugin: PluginDef | PluginDefResolvable) {
+  return `${plugin.plugin.namespace}/${plugin.plugin.name}`;
+}
+
+/** Converts a PluginDef object (full definition) into a PluginInfo object (restricted definition) */
+function pluginDefToInfo(plugin?: PluginDef): PluginInfo | undefined {
+  return plugin && {
+    name: plugin.plugin.name,
+    namespace: plugin.plugin.namespace,
+    version: plugin.plugin.version,
+  };
+}
+
+/** Returns info about a registered plugin on the BYTM interface by its name and namespace properties, or undefined if the plugin isn't registered */
+export function getPluginInfo(name: string, namespace: string): PluginInfo | undefined
+/** Returns info about a registered plugin on the BYTM interface, or undefined if the plugin isn't registered */
+export function getPluginInfo(plugin: PluginDefResolvable): PluginInfo | undefined
+/** Returns info about a registered plugin on the BYTM interface, or undefined if the plugin isn't registered */
+export function getPluginInfo(...args: [pluginDefOrName: PluginDefResolvable | string, namespace?: string]): PluginInfo | undefined {
+  return args.length === 2
+    ? pluginDefToInfo(plugins.get(`${args[1]}/${args[0]}`))
+    : pluginDefToInfo(plugins.get(getPluginKey(args[0] as PluginDefResolvable)));
+}
+
+/** Validates the passed PluginDef object and returns an array of errors */
+function validatePluginDef(pluginDef: Partial<PluginDef>) {
+  const errors = [] as string[];
+
+  const addNoPropErr = (prop: string, type: string) =>
+    errors.push(t("plugin_validation_error_no_property", prop, type));
+
+  // def.plugin and its properties:
+  typeof pluginDef.plugin !== "object" && addNoPropErr("plugin", "object");
+  const { plugin } = pluginDef;
+  !plugin?.name && addNoPropErr("plugin.name", "string");
+  !plugin?.namespace && addNoPropErr("plugin.namespace", "string");
+  !plugin?.version && addNoPropErr("plugin.version", "[major: number, minor: number, patch: number]");
+
+  return errors.length > 0 ? errors : undefined;
+}
+
+/** Registers a plugin on the BYTM interface */
+export function registerPlugin(pluginDef: PluginDef): PluginRegisterResult {
+  const validationErrors = validatePluginDef(pluginDef);
+  if(validationErrors) {
+    error(`Failed to register plugin${pluginDef?.plugin?.name ? ` '${pluginDef?.plugin?.name}'` : ""} with invalid definition:\n- ${validationErrors.join("\n- ")}`, LogLevel.Info);
+    throw new Error(`Invalid plugin definition:\n- ${validationErrors.join("\n- ")}`);
+  }
+
+  const { plugin: { name } } = pluginDef;
+  plugins.set(getPluginKey(pluginDef), pluginDef);
+
+  info(`Registered plugin: ${name}`, LogLevel.Info);
+
+  return {
+    info: getPluginInfo(pluginDef)!,
+  };
+}
+
 //#MARKER proxy functions
 
 /** Returns the current feature config, with sensitive values replaced by `undefined` */

+ 82 - 2
src/types.ts

@@ -2,7 +2,7 @@ import type * as consts from "./constants";
 import type { scriptInfo } from "./constants";
 import type { addSelectorListener } from "./observers";
 import type resources from "../assets/resources.json";
-import type langMapping from "../assets/locales.json";
+import type locales from "../assets/locales.json";
 import type { getResourceUrl, getSessionId, getVideoTime, TrLocale, t, tp } from "./utils";
 import type { getFeatures, saveFeatures } from "./config";
 
@@ -28,7 +28,7 @@ export type Domain = "yt" | "ytm";
 export type HttpUrlString = `http://${string}` | `https://${string}`;
 
 /** Key of a resource in `assets/resources.json` and extra keys defined by `tools/post-build.ts` */
-export type ResourceKey = keyof typeof resources | `trans-${keyof typeof langMapping}` | "changelog";
+export type ResourceKey = keyof typeof resources | `trans-${keyof typeof locales}` | "changelog";
 
 /** Describes a single hotkey */
 export type HotkeyObj = {
@@ -48,6 +48,84 @@ export type LyricsCacheEntry = {
   added: number;
 };
 
+//#MARKER plugins
+
+// /** Intents (permissions) BYTM has to grant to the plugin for it to work */
+// export enum PluginIntent {
+//   /** Plugin has access to hidden config values */
+//   HiddenConfigValues = 1,
+//   /** Plugin can write to the feature configuration */
+//   WriteFeatureConfig = 2,
+//   /** Plugin can write to the lyrics cache */
+//   WriteLyricsCache = 4,
+//   /** Plugin can add new translations and overwrite existing ones */
+//   WriteTranslations = 8,
+//   /** Plugin can create modal dialogs */
+//   CreateModalDialogs = 16,
+// }
+
+/** Result of a plugin registration */
+export type PluginRegisterResult = {
+  /** Public info about the registered plugin */
+  info: PluginInfo;
+}
+
+/** Object that describes a plugin out of the restricted perspective of another plugin */
+export type PluginInfo =
+  Pick<PluginDef["plugin"],
+    | "name"
+    | "namespace"
+    | "version"
+  >;
+
+/** Minimum part of the PluginDef object needed to make up the resolvable plugin identifier */
+export type PluginDefResolvable = { plugin: Pick<PluginDef["plugin"], "name" | "namespace"> };
+
+/** An object that describes a BYTM plugin */
+export type PluginDef = {
+  plugin: {
+    /** Name of the plugin */
+    name: string;
+    /**
+     * Adding the namespace and the name property makes the unique identifier for a plugin.  
+     * If one exists with the same name and namespace as this plugin, it may be overwritten at registration.  
+     * I recommend to set this value to a URL pointing to your homepage, or the author's username.
+     */
+    namespace: string;
+    /** Version of the plugin as an array containing three whole numbers: `[major_version, minor_version, patch_version]` */
+    version: [major: number, minor: number, patch: number];
+    /**
+     * Descriptions of at least en_US and optionally any other locale supported by BYTM.  
+     * When an untranslated locale is set, the description will default to the value of en_US
+     */
+    description: Partial<Record<keyof typeof locales, string>> & {
+      en_US: string;
+    };
+    /** URL to the plugin's icon - recommended size: 48x48 to 128x128 */
+    iconUrl?: string;
+    /** Homepage URLs for the plugin */
+    homepage?: {
+      /** URL to the plugin's GitHub repo */
+      github?: string;
+      /** URL to the plugin's GreasyFork page */
+      greasyfork?: string;
+      /** URL to the plugin's OpenUserJS page */
+      openuserjs?: string;
+    };
+  };
+  // /** TODO(v2.3 / v3): Intents (permissions) BYTM has to grant the plugin for it to work */
+  // intents?: Array<PluginIntent>;
+  /** Info about the plugin contributors */
+  contributors?: Array<{
+    /** Name of this contributor */
+    name: string;
+    /** (optional) Email address of this contributor */
+    email?: string;
+    /** (optional) URL to this plugin contributor's homepage / GitHub profile */
+    url?: string;
+  }>;
+};
+
 /** All functions exposed by the interface on the global `BYTM` object */
 export type InterfaceFunctions = {
   /** Adds a listener to one of the already present SelectorObserver instances */
@@ -105,6 +183,8 @@ declare global {
   }
 }
 
+//#MARKER features
+
 export type FeatureKey = keyof FeatureConfig;
 
 export type FeatureCategory =