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

getName() ?>

+

getTitle() ?>

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 @@ section('title') ?> $plugin->getName(), - 'type' => $type, + 'pluginTitle' => $plugin->getTitle(), + 'type' => $type, ]) ?> endSection() ?> section('pageTitle') ?> $plugin->getName(), - 'type' => $type, + 'pluginTitle' => $plugin->getTitle(), + 'type' => $type, ]) ?> 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 @@ extend('_layout') ?> section('title') ?> -getName() ?> +getTitle() ?> endSection() ?> section('pageTitle') ?> -getName() ?> +getTitle() ?> endSection() ?> 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'; }