diff --git a/.prettierrc.json b/.prettierrc.json
index d567a64c..cae76d94 100644
--- a/.prettierrc.json
+++ b/.prettierrc.json
@@ -2,7 +2,7 @@
"trailingComma": "es5",
"overrides": [
{
- "files": "*.md",
+ "files": ["*.md", "*.mdx"],
"options": {
"proseWrap": "always"
}
diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs
index b481bd2f..27768002 100644
--- a/docs/astro.config.mjs
+++ b/docs/astro.config.mjs
@@ -183,6 +183,35 @@ export default defineConfig({
},
],
},
+ {
+ label: "Plugins",
+ badge: {
+ text: "Experimental",
+ },
+ items: [
+ {
+ label: "Introduction",
+ link: "/plugins/",
+ },
+ {
+ label: "Creating a plugin",
+ link: "/plugins/create",
+ },
+ {
+ label: "Reference",
+ items: [
+ {
+ label: "manifest.json",
+ link: "/plugins/manifest",
+ },
+ {
+ label: "hooks",
+ link: "/plugins/hooks",
+ },
+ ],
+ },
+ ],
+ },
],
editLink: {
baseUrl:
diff --git a/docs/src/content/docs/en/plugins/create.mdx b/docs/src/content/docs/en/plugins/create.mdx
new file mode 100644
index 00000000..2941e830
--- /dev/null
+++ b/docs/src/content/docs/en/plugins/create.mdx
@@ -0,0 +1,56 @@
+---
+title: Creating a Plugin
+---
+
+import { FileTree, Steps } from "@astrojs/starlight/components";
+
+In order to get started, you first need to
+[setup your Castopod dev environment](https://code.castopod.org/adaures/castopod/-/blob/develop/CONTRIBUTING-DEV.md).
+
+## Using the create command
+
+To quickly get you started, you can create a plugin using the following CLI
+command:
+
+```sh
+php spark plugins:create
+```
+
+👉 Follow the CLI instructions: you will be prompted for metadata and hooks
+definitions to generate the [plugin folder](./#plugin-folder-structure) for you.
+
+## Manual setup
+
+
+1. create a plugin folder inside a vendor directory
+
+
+ - plugins
+ - acme
+ - **hello-world/**
+ - …
+
+
+
+2. add a manifest.json file
+
+
+
+ - hello-world
+ - **manifest.json**
+
+
+
+ See the [manifest reference](./manifest).
+
+3. add the Plugin.php class
+
+
+
+ - hello-world
+ - manifest.json
+ - **Plugin.php**
+
+
+
+
diff --git a/docs/src/content/docs/en/plugins/helpers.mdx b/docs/src/content/docs/en/plugins/helpers.mdx
new file mode 100644
index 00000000..3630207c
--- /dev/null
+++ b/docs/src/content/docs/en/plugins/helpers.mdx
@@ -0,0 +1,3 @@
+---
+title: BasePlugin
+---
diff --git a/docs/src/content/docs/en/plugins/hooks.mdx b/docs/src/content/docs/en/plugins/hooks.mdx
new file mode 100644
index 00000000..cd683170
--- /dev/null
+++ b/docs/src/content/docs/en/plugins/hooks.mdx
@@ -0,0 +1,61 @@
+---
+title: Hooks reference
+---
+
+Hooks are methods that live in the Plugin class, they are executed in parts of
+the Castopod codebase.
+
+## List
+
+| Hooks | Executes in |
+| ---------------- | ----------- |
+| rssBeforeChannel | RSS Feed |
+| rssAfterChannel | RSS Feed |
+| rssBeforeItem | RSS Feed |
+| rssAfterItem | RSS Feed |
+| siteHead | Website |
+
+### rssBeforeChannel
+
+```php
+public function rssBeforeChannel(Podcast $podcast): void
+{
+ // …
+}
+```
+
+### rssAfterChannel
+
+```php
+public function rssAfterChannel(Podcast $podcast, SimpleRSSElement $rss): void
+{
+ // …
+}
+```
+
+### rssBeforeItem
+
+```php
+public function rssBeforeItem(Episode $episode): void
+{
+ // …
+}
+```
+
+### rssAfterItem
+
+```php
+public function rssAfterItem(Epsiode $episode, SimpleRSSElement $rss): void
+{
+ // …
+}
+```
+
+### siteHead
+
+```php
+public function siteHead(): void
+{
+ // …
+}
+```
diff --git a/docs/src/content/docs/en/plugins/index.mdx b/docs/src/content/docs/en/plugins/index.mdx
new file mode 100644
index 00000000..4e16b87c
--- /dev/null
+++ b/docs/src/content/docs/en/plugins/index.mdx
@@ -0,0 +1,133 @@
+---
+title: Castopod Plugins
+---
+
+import { FileTree, Aside } from "@astrojs/starlight/components";
+
+Plugins are ways to extend Castopod's core features.
+
+## Plugin folder structure
+
+
+
+- hello-world
+ - i18n
+ - en.json
+ - fr.json
+ - …
+ - icon.svg
+ - [manifest.json](./manifest) // required
+ - [Plugin.php](#plugin-class) // required
+ - README.md
+
+
+
+Plugins reside in the `plugins` folder under a **vendor** folder, ie. the
+organisation or person who authored the plugin.
+
+
+
+- **plugins**
+ - acme
+ - hello-world/
+ - …
+ - atlantis/
+
+
+
+### manifest.json (required)
+
+The plugin manifest is a JSON file containing your plugin's metadata and
+permissions.
+
+This file will determine whether a plugin is valid or not. The minimal required
+data being:
+
+```json
+{
+ "name": "acme/hello-world",
+ "version": "1.0.0"
+}
+```
+
+Checkout the [manifest.json reference](./manifest).
+
+
Plugin class (required)
+
+This is where your plugin's logic will live.
+
+The Plugin class must extend Castopod's BasePlugin class and implement one or
+multiple [Hooks](./hooks) (methods).
+
+```php
+// Plugin.php
+
+
+The Plugin class name is determined by its `vendor/name` pair.
+For example, a plugin living under the `acme/hello-world` folder must be named
+`AcmeHelloWorldPlugin`:
+
+- the first letter of every word is capitalized (ie. PascalCase)
+- any special caracter is removed
+- the `Plugin` suffix is added
+
+
+
+### README.md
+
+The `README.md` file is loaded into the plugin's view page for the user to
+read.
+It should be used for any additional information to help guide the user in using
+the plugin.
+
+### icon.svg
+
+The plugin icon is displayed next to its title, it is an SVG file intended to
+give a graphical representation of the plugin.
+
+The icon should be squared, and be legible in a 64px by 64px circle.
+
+### Internationalization (i18n)
+
+Translation strings live under the `i18n` folder. Translation files are JSON
+files named as locale keys:
+
+
+
+- **i18n**
+ - en.json // default locale
+ - fr.json
+ - de.json
+ - …
+
+
+
+Supported locales are:
+`br`,`ca`,`de`,`en`,`es`,`fr`,`nn-no`,`pl`,`pt-br`,`sr-latn`,`zh-hans`.
+
+The translation strings allow you to translate the title, description and
+settings keys.
+
+```json
+{
+ "title": "Hello, World!",
+ "description": "A Castopod plugin to greet the world!",
+ "settings": {
+ "general": {},
+ "podcast": {},
+ "episode": {}
+ }
+}
+```
diff --git a/docs/src/content/docs/en/plugins/manifest.mdx b/docs/src/content/docs/en/plugins/manifest.mdx
new file mode 100644
index 00000000..b1754f56
--- /dev/null
+++ b/docs/src/content/docs/en/plugins/manifest.mdx
@@ -0,0 +1,59 @@
+---
+title: manifest.json reference
+---
+
+This page details the attributes of a Castopod Plugin's manifest, which must be
+a JSON file.
+
+### name (required)
+
+The plugin name, including 'vendor-name/' prefix.
+
+### version (required)
+
+The plugin's semantic version (eg. 1.0.0) - see https://semver.org/
+
+### description
+
+The plugin's description. This helps people discover your plugin as it's listed
+in repositories
+
+### authors
+
+Array one or more persons having authored the plugin. A person is an object with
+a required "name" field and optional "email" and "url" fields:
+
+```json
+{
+ "name": "Jean D'eau",
+ "email": "jean.deau@example.com",
+ "url": "https://example.com/"
+}
+```
+
+Or you can shorten the object into a single string:
+
+```json
+"Jean D'eau (https://example.com/)"
+```
+
+### homepage
+
+The URL to the project homepage.
+
+### license
+
+You should specify a license for your plugin so that people know how they are
+permitted to use it, and any restrictions you're placing on it.
+
+### private
+
+### keywords
+
+### hooks
+
+### settings
+
+### files
+
+### repository
diff --git a/modules/Plugins/Commands/CreatePlugin.php b/modules/Plugins/Commands/CreatePlugin.php
new file mode 100644
index 00000000..a9b31a80
--- /dev/null
+++ b/modules/Plugins/Commands/CreatePlugin.php
@@ -0,0 +1,159 @@
+ ['use App\Entities\Podcast;'],
+ 'rssAfterChannel' => ['use App\Entities\Podcast;', 'use App\Libraries\SimpleRSSElement;'],
+ 'rssBeforeItem' => ['use App\Entities\Episode;'],
+ 'rssAfterItem' => ['use App\Entities\Episode;', 'use App\Libraries\SimpleRSSElement;'],
+ 'siteHead' => [],
+ ];
+
+ protected const HOOKS_METHODS = [
+ 'rssBeforeChannel' => ' public function rssBeforeChannel(Podcast $podcast): void
+ {
+ // YOUR CODE HERE
+ }',
+ 'rssAfterChannel' => ' public function rssAfterChannel(Podcast $podcast, SimpleRSSElement $channel): void
+ {
+ // YOUR CODE HERE
+ }',
+ 'rssBeforeItem' => ' public function rssBeforeItem(Episode $episode): void
+ {
+ // YOUR CODE HERE
+ }',
+ 'rssAfterItem' => ' public function rssAfterItem(Episode $episode, SimpleRSSElement $item): void
+ {
+ // YOUR CODE HERE
+ }',
+ 'siteHead' => ' public function siteHead(): void
+ {
+ // YOUR CODE HERE
+ }',
+ ];
+
+ /**
+ * @var string
+ */
+ protected $group = 'Plugins';
+
+ /**
+ * @var string
+ */
+ protected $name = 'plugins:create';
+
+ /**
+ * @var string
+ */
+ protected $description = 'Generates a new plugin folder based on a template.';
+
+ /**
+ * Actually execute a command.
+ *
+ * @param list $params
+ */
+ #[Override]
+ public function run(array $params): void
+ {
+ $pluginName = CLI::prompt(
+ 'Plugin name (/)',
+ 'acme/hello-world',
+ Manifest::VALIDATION_RULES['name']
+ );
+ CLI::newLine();
+ $description = CLI::prompt('Description', '', Manifest::VALIDATION_RULES['description']);
+ CLI::newLine();
+ $license = CLI::prompt('License', 'UNLICENSED', Manifest::VALIDATION_RULES['license']);
+ CLI::newLine();
+ $hooks = CLI::promptByMultipleKeys('Which hooks do you want to implement?', Plugins::HOOKS);
+
+ $nameParts = explode('/', $pluginName);
+ $vendor = $nameParts[0];
+ $name = $nameParts[1];
+
+ /** @var PluginsConfig $pluginsConfig */
+ $pluginsConfig = config('Plugins');
+
+ // 1. create plugin directory if not existent
+ $pluginDirectory = $pluginsConfig->folder . $vendor . DIRECTORY_SEPARATOR . $name;
+ if (! file_exists($pluginDirectory)) {
+ mkdir($pluginDirectory, 0755, true);
+ }
+
+ // 2. get contents of templates
+ $manifestTemplate = file_get_contents(__DIR__ . '/plugin-template/manifest.tpl.json');
+
+ if (! $manifestTemplate) {
+ throw new Exception('Failed to get manifest template.');
+ }
+
+ $pluginClassTemplate = file_get_contents(__DIR__ . '/plugin-template/Plugin.tpl.php');
+
+ if (! $pluginClassTemplate) {
+ throw new Exception('Failed to get Plugin class template.');
+ }
+
+ // 3. edit templates' contents
+ $manifestContents = str_replace('"name": ""', '"name": "' . $pluginName . '"', $manifestTemplate);
+ $manifestContents = str_replace(
+ '"description": ""',
+ '"description": "' . $description . '"',
+ $manifestContents
+ );
+ $manifestContents = str_replace('"license": ""', '"license": "' . $license . '"', $manifestContents);
+ $manifestContents = str_replace(
+ '"hooks": []',
+ '"hooks": ["' . implode('", "', $hooks) . '"]',
+ $manifestContents
+ );
+
+ $pluginClassName = str_replace(
+ ' ',
+ '',
+ ucwords(str_replace(['-', '_', '.'], ' ', $vendor . ' ' . $name)) . 'Plugin'
+ );
+ $pluginClassContents = str_replace('class Plugin', 'class ' . $pluginClassName, $pluginClassTemplate);
+
+ $allImports = [];
+ $allMethods = [];
+ foreach ($hooks as $hook) {
+ $allImports = [...$allImports, ...self::HOOKS_IMPORTS[$hook]];
+ $allMethods = [...$allMethods, self::HOOKS_METHODS[$hook]];
+ }
+
+ $imports = implode(PHP_EOL, array_unique($allImports));
+ $methods = implode(PHP_EOL . PHP_EOL, $allMethods);
+ $pluginClassContents = str_replace('// IMPORTS_HERE', $imports, $pluginClassContents);
+ $pluginClassContents = str_replace(' // HOOKS_HERE', $methods, $pluginClassContents);
+
+ $manifest = $pluginDirectory . '/manifest.json';
+ $pluginClass = $pluginDirectory . '/Plugin.php';
+
+ if (! file_put_contents($manifest, $manifestContents)) {
+ throw new Exception('Failed to create manifest.json file.');
+ }
+
+ if (! file_put_contents($pluginClass, $pluginClassContents)) {
+ throw new Exception('Failed to create Plugin class file.');
+ }
+
+ CLI::newLine(1);
+ CLI::write(
+ sprintf('Plugin %s created in %s', CLI::color($pluginName, 'white'), CLI::color($pluginDirectory, 'white')),
+ 'green'
+ );
+ }
+}
diff --git a/modules/Plugins/Commands/UninstallPlugin.php b/modules/Plugins/Commands/UninstallPlugin.php
index bd573875..3a55ea55 100644
--- a/modules/Plugins/Commands/UninstallPlugin.php
+++ b/modules/Plugins/Commands/UninstallPlugin.php
@@ -30,7 +30,7 @@ class UninstallPlugin extends BaseCommand
*
* @var string
*/
- protected $description = '';
+ protected $description = 'Removes a plugin from the plugins directory.';
/**
* The Command's Usage
diff --git a/modules/Plugins/Commands/plugin-template/Plugin.tpl.php b/modules/Plugins/Commands/plugin-template/Plugin.tpl.php
new file mode 100644
index 00000000..edf615e5
--- /dev/null
+++ b/modules/Plugins/Commands/plugin-template/Plugin.tpl.php
@@ -0,0 +1,11 @@
+back()
->with('message', lang('Plugins.messages.saveSettingsSuccess', [
- 'pluginName' => $plugin->getName(),
+ 'pluginTitle' => $plugin->getTitle(),
]));
}
diff --git a/modules/Plugins/Core/BasePlugin.php b/modules/Plugins/Core/BasePlugin.php
index dff0fd5c..3598aef3 100644
--- a/modules/Plugins/Core/BasePlugin.php
+++ b/modules/Plugins/Core/BasePlugin.php
@@ -233,17 +233,17 @@ abstract class BasePlugin implements PluginInterface
return $this->package;
}
- final public function getName(): string
+ final public function getTitle(): string
{
- $key = sprintf('Plugin.%s.name', $this->key);
- /** @var string $name */
- $name = lang($key);
+ $key = sprintf('Plugin.%s.title', $this->key);
+ /** @var string $title */
+ $title = lang($key);
- if ($name === $key) {
+ if ($title === $key) {
return $this->manifest->name;
}
- return $name;
+ return $title;
}
final public function getDescription(): ?string
diff --git a/modules/Plugins/Language/en/Plugins.php b/modules/Plugins/Language/en/Plugins.php
index 031e3271..2bd33360 100644
--- a/modules/Plugins/Language/en/Plugins.php
+++ b/modules/Plugins/Language/en/Plugins.php
@@ -19,9 +19,9 @@ return [
'declaredHooks' => 'Declared hooks',
'settings' => 'Settings',
'settingsTitle' => '{type, select,
- podcast {{pluginName} podcast settings}
- episode {{pluginName} episode settings}
- other {{pluginName} general settings}
+ podcast {{pluginTitle} podcast settings}
+ episode {{pluginTitle} episode settings}
+ other {{pluginTitle} general settings}
}',
'view' => 'View',
'activate' => 'Activate',
@@ -39,7 +39,7 @@ return [
'noDescription' => 'No description',
'noReadme' => 'No README file found.',
'messages' => [
- 'saveSettingsSuccess' => '{pluginName} settings were successfully saved!',
+ 'saveSettingsSuccess' => '{pluginTitle} settings were successfully saved!',
],
'errors' => [
'manifestError' => 'Plugin manifest has errors',
diff --git a/modules/Plugins/Manifest/Manifest.php b/modules/Plugins/Manifest/Manifest.php
index eb8932e3..f5d0ce3c 100644
--- a/modules/Plugins/Manifest/Manifest.php
+++ b/modules/Plugins/Manifest/Manifest.php
@@ -25,7 +25,7 @@ class Manifest extends ManifestObject
/**
* @var array
*/
- protected const VALIDATION_RULES = [
+ public const VALIDATION_RULES = [
'name' => 'required|max_length[128]|regex_match[/^[a-z0-9]([_.-]?[a-z0-9]+)*\/[a-z0-9]([_.-]?[a-z0-9]+)*$/]',
'version' => 'required|regex_match[/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/]',
'description' => 'permit_empty|max_length[256]',
diff --git a/themes/cp_admin/_sidebar.php b/themes/cp_admin/_sidebar.php
index 1fe2170f..3bce82a8 100644
--- a/themes/cp_admin/_sidebar.php
+++ b/themes/cp_admin/_sidebar.php
@@ -88,7 +88,7 @@ $navigation = [
foreach (plugins()->getActivePlugins() as $plugin) {
$route = route_to('plugins-view', $plugin->getVendor(), $plugin->getPackage());
$navigation['plugins']['items'][] = $route;
- $navigation['plugins']['items-labels'][$route] = $plugin->getName();
+ $navigation['plugins']['items-labels'][$route] = $plugin->getTitle();
$navigation['plugins']['items-permissions'][$route] = 'plugins.manage';
}
diff --git a/themes/cp_admin/episode/_sidebar.php b/themes/cp_admin/episode/_sidebar.php
index fb1a00df..1852c7d4 100644
--- a/themes/cp_admin/episode/_sidebar.php
+++ b/themes/cp_admin/episode/_sidebar.php
@@ -35,7 +35,7 @@ $episodeNavigation = [
foreach (plugins()->getPluginsWithEpisodeSettings() as $plugin) {
$route = route_to('plugins-settings-episode', $plugin->getVendor(), $plugin->getPackage(), $podcast->id, $episode->id);
$episodeNavigation['plugins']['items'][] = $route;
- $episodeNavigation['plugins']['items-labels'][$route] = $plugin->getName();
+ $episodeNavigation['plugins']['items-labels'][$route] = $plugin->getTitle();
$episodeNavigation['plugins']['items-permissions'][$route] = 'episodes.edit';
}
diff --git a/themes/cp_admin/plugins/_plugin.php b/themes/cp_admin/plugins/_plugin.php
index b58d4fe1..225871cc 100644
--- a/themes/cp_admin/plugins/_plugin.php
+++ b/themes/cp_admin/plugins/_plugin.php
@@ -18,7 +18,7 @@ use Modules\Plugins\Core\PluginStatus;
-
+
= $plugin->getVendor() ?>
diff --git a/themes/cp_admin/plugins/settings.php b/themes/cp_admin/plugins/settings.php
index 587fd772..1a054167 100644
--- a/themes/cp_admin/plugins/settings.php
+++ b/themes/cp_admin/plugins/settings.php
@@ -2,15 +2,15 @@
= $this->section('title') ?>
= lang('Plugins.settingsTitle', [
- 'pluginName' => $plugin->getName(),
- 'type' => $type,
+ 'pluginTitle' => $plugin->getTitle(),
+ 'type' => $type,
]) ?>
= $this->endSection() ?>
= $this->section('pageTitle') ?>
= lang('Plugins.settingsTitle', [
- 'pluginName' => $plugin->getName(),
- 'type' => $type,
+ 'pluginTitle' => $plugin->getTitle(),
+ 'type' => $type,
]) ?>
= $this->endSection() ?>
diff --git a/themes/cp_admin/plugins/view.php b/themes/cp_admin/plugins/view.php
index 47702922..fecff29a 100644
--- a/themes/cp_admin/plugins/view.php
+++ b/themes/cp_admin/plugins/view.php
@@ -5,11 +5,11 @@
= $this->extend('_layout') ?>
= $this->section('title') ?>
-= $plugin->getName() ?>
+= $plugin->getTitle() ?>
= $this->endSection() ?>
= $this->section('pageTitle') ?>
-= $plugin->getName() ?>
+= $plugin->getTitle() ?>
= $this->endSection() ?>
= $this->section('headerLeft') ?>
diff --git a/themes/cp_admin/podcast/_sidebar.php b/themes/cp_admin/podcast/_sidebar.php
index a48f7ed2..6f6d8f6d 100644
--- a/themes/cp_admin/podcast/_sidebar.php
+++ b/themes/cp_admin/podcast/_sidebar.php
@@ -92,7 +92,7 @@ $podcastNavigation = [
foreach (plugins()->getPluginsWithPodcastSettings() as $plugin) {
$route = route_to('plugins-settings-podcast', $plugin->getVendor(), $plugin->getPackage(), $podcast->id);
$podcastNavigation['plugins']['items'][] = $route;
- $podcastNavigation['plugins']['items-labels'][$route] = $plugin->getName();
+ $podcastNavigation['plugins']['items-labels'][$route] = $plugin->getTitle();
$podcastNavigation['plugins']['items-permissions'][$route] = 'edit';
}