docs(plugins): add experimental plugins section + plugins:create command to create plugin via CLI

This commit is contained in:
Yassine Doghri 2024-06-08 18:30:36 +00:00
parent 91dc8c8325
commit 8f8c61eaae
21 changed files with 542 additions and 24 deletions

View File

@ -2,7 +2,7 @@
"trailingComma": "es5",
"overrides": [
{
"files": "*.md",
"files": ["*.md", "*.mdx"],
"options": {
"proseWrap": "always"
}

View File

@ -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:

View File

@ -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
<Steps>
1. create a plugin folder inside a vendor directory
<FileTree>
- plugins
- acme
- **hello-world/**
- …
</FileTree>
2. add a manifest.json file
<FileTree>
- hello-world
- **manifest.json**
</FileTree>
See the [manifest reference](./manifest).
3. add the Plugin.php class
<FileTree>
- hello-world
- manifest.json
- **Plugin.php**
</FileTree>
</Steps>

View File

@ -0,0 +1,3 @@
---
title: BasePlugin
---

View File

@ -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
{
// …
}
```

View File

@ -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
<FileTree>
- hello-world
- i18n
- en.json
- fr.json
- …
- icon.svg
- [manifest.json](./manifest) // required
- [Plugin.php](#plugin-class) // required
- README.md
</FileTree>
Plugins reside in the `plugins` folder under a **vendor** folder, ie. the
organisation or person who authored the plugin.
<FileTree>
- **plugins**
- acme
- hello-world/
- …
- atlantis/
</FileTree>
### 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).
<h3 id="plugin-class">Plugin class (required)</h3>
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
<?php
declare(strict_types=1);
use Modules\Plugins\Core\BasePlugin;
class AcmeHelloWorldPlugin extends BasePlugin
{
// …
}
```
<Aside type="note">
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
</Aside>
### 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:
<FileTree>
- **i18n**
- en.json // default locale
- fr.json
- de.json
- …
</FileTree>
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": {}
}
}
```

View File

@ -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 <jean.deau@example.com> (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

View File

@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
namespace Modules\Plugins\Commands;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
use Exception;
use Modules\Plugins\Config\Plugins as PluginsConfig;
use Modules\Plugins\Core\Plugins;
use Modules\Plugins\Manifest\Manifest;
use Override;
class CreatePlugin extends BaseCommand
{
protected const HOOKS_IMPORTS = [
'rssBeforeChannel' => ['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<string> $params
*/
#[Override]
public function run(array $params): void
{
$pluginName = CLI::prompt(
'Plugin name (<vendor>/<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'
);
}
}

View File

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

View File

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
// IMPORTS_HERE
use Modules\Plugins\Core\BasePlugin;
class Plugin extends BasePlugin
{
// HOOKS_HERE
}

View File

@ -0,0 +1,7 @@
{
"name": "",
"version": "0.1.0",
"description": "",
"license": "",
"hooks": []
}

View File

@ -206,7 +206,7 @@ class PluginController extends BaseController
return redirect()->back()
->with('message', lang('Plugins.messages.saveSettingsSuccess', [
'pluginName' => $plugin->getName(),
'pluginTitle' => $plugin->getTitle(),
]));
}

View File

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

View File

@ -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',

View File

@ -25,7 +25,7 @@ class Manifest extends ManifestObject
/**
* @var array<string,string>
*/
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]',

View File

@ -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';
}

View File

@ -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';
}

View File

@ -18,7 +18,7 @@ use Modules\Plugins\Core\PluginStatus;
</div>
<img class="rounded-full min-w-16 max-w-16 aspect-square" src="<?= $plugin->getIconSrc() ?>">
<div class="flex flex-col items-start mt-2 mb-6">
<h2 class="flex items-center text-xl font-bold font-display gap-x-2" title="<?= $plugin->getName() ?>"><a class="line-clamp-1" href="<?= route_to('plugins-view', $plugin->getVendor(), $plugin->getPackage()) ?>" class="hover:underline decoration-accent"><?= $plugin->getName() ?></a></h2>
<h2 class="flex items-center text-xl font-bold font-display gap-x-2" title="<?= $plugin->getTitle() ?>"><a class="line-clamp-1" href="<?= route_to('plugins-view', $plugin->getVendor(), $plugin->getPackage()) ?>" class="hover:underline decoration-accent"><?= $plugin->getTitle() ?></a></h2>
<p class="inline-flex font-mono text-xs">
<span class="inline-flex tracking-wide bg-gray-100">
<a href="<?= route_to('plugins-vendor', $plugin->getVendor()) ?>" class="underline underline-offset-2 decoration-2 decoration-dotted hover:decoration-solid decoration-accent"><?= $plugin->getVendor() ?></a>

View File

@ -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() ?>

View File

@ -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') ?>

View File

@ -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';
}