mirror of
https://code.castopod.org/adaures/castopod.git
synced 2024-09-28 04:31:47 +02:00
feat(plugins): display errors when plugin is invalid instead of crashing
This commit is contained in:
parent
45ac2a4be9
commit
8ec79097bb
@ -21,7 +21,6 @@ use Modules\Plugins\Manifest\Manifest;
|
|||||||
use Modules\Plugins\Manifest\Person;
|
use Modules\Plugins\Manifest\Person;
|
||||||
use Modules\Plugins\Manifest\Repository;
|
use Modules\Plugins\Manifest\Repository;
|
||||||
use Modules\Plugins\Manifest\Settings;
|
use Modules\Plugins\Manifest\Settings;
|
||||||
use RuntimeException;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @property string $key
|
* @property string $key
|
||||||
@ -33,7 +32,12 @@ abstract class BasePlugin implements PluginInterface
|
|||||||
|
|
||||||
protected string $iconSrc;
|
protected string $iconSrc;
|
||||||
|
|
||||||
protected bool $active;
|
/**
|
||||||
|
* @var array<string,string>
|
||||||
|
*/
|
||||||
|
protected array $errors = [];
|
||||||
|
|
||||||
|
protected PluginStatus $status;
|
||||||
|
|
||||||
protected Manifest $manifest;
|
protected Manifest $manifest;
|
||||||
|
|
||||||
@ -51,20 +55,30 @@ abstract class BasePlugin implements PluginInterface
|
|||||||
$manifestContents = file_get_contents($manifestPath);
|
$manifestContents = file_get_contents($manifestPath);
|
||||||
|
|
||||||
if (! $manifestContents) {
|
if (! $manifestContents) {
|
||||||
throw new RuntimeException(sprintf('Plugin manifest "%s" is missing!', $manifestPath));
|
$manifestContents = '{}';
|
||||||
|
$this->errors['manifest'] = lang('Plugins.errors.manifestMissing', [
|
||||||
|
'manifestPath' => $manifestPath,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @var array<mixed>|null $manifestData */
|
/** @var array<mixed>|null $manifestData */
|
||||||
$manifestData = json_decode($manifestContents, true);
|
$manifestData = json_decode($manifestContents, true);
|
||||||
|
|
||||||
if ($manifestData === null) {
|
if ($manifestData === null) {
|
||||||
throw new RuntimeException(sprintf('Plugin manifest "%s" is not a valid JSON', $manifestPath), 1);
|
$manifestData = [];
|
||||||
|
$this->errors['manifest'] = lang('Plugins.errors.manifestJsonInvalid', [
|
||||||
|
'manifestPath' => $manifestPath,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->manifest = new Manifest($manifestData);
|
$this->manifest = new Manifest($this->key, $manifestData);
|
||||||
|
$this->errors = [...$this->errors, ...Manifest::getPluginErrors($this->key)];
|
||||||
|
|
||||||
// check that plugin is active
|
if ($this->errors !== []) {
|
||||||
$this->active = get_plugin_option($this->key, 'active') ?? false;
|
$this->status = PluginStatus::INVALID;
|
||||||
|
} else {
|
||||||
|
$this->status = get_plugin_option($this->key, 'active') ? PluginStatus::ACTIVE : PluginStatus::INACTIVE;
|
||||||
|
}
|
||||||
|
|
||||||
$this->iconSrc = $this->loadIcon($directory . '/icon.svg');
|
$this->iconSrc = $this->loadIcon($directory . '/icon.svg');
|
||||||
|
|
||||||
@ -98,9 +112,17 @@ abstract class BasePlugin implements PluginInterface
|
|||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
final public function isActive(): bool
|
final public function getStatus(): PluginStatus
|
||||||
{
|
{
|
||||||
return $this->active;
|
return $this->status;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,string>
|
||||||
|
*/
|
||||||
|
final public function getErrors(): array
|
||||||
|
{
|
||||||
|
return $this->errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
final public function isHookDeclared(string $name): bool
|
final public function isHookDeclared(string $name): bool
|
||||||
@ -144,19 +166,6 @@ abstract class BasePlugin implements PluginInterface
|
|||||||
return $this->iconSrc;
|
return $this->iconSrc;
|
||||||
}
|
}
|
||||||
|
|
||||||
final public function doesManifestHaveErrors(): bool
|
|
||||||
{
|
|
||||||
return $this->getManifestErrors() !== [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string,string>
|
|
||||||
*/
|
|
||||||
final public function getManifestErrors(): array
|
|
||||||
{
|
|
||||||
return $this->manifest::$errors;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return Field[]
|
* @return Field[]
|
||||||
*/
|
*/
|
||||||
|
12
modules/Plugins/Core/PluginStatus.php
Normal file
12
modules/Plugins/Core/PluginStatus.php
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Modules\Plugins\Core;
|
||||||
|
|
||||||
|
enum PluginStatus: string
|
||||||
|
{
|
||||||
|
case INVALID = 'invalid';
|
||||||
|
case INACTIVE = 'inactive';
|
||||||
|
case ACTIVE = 'active';
|
||||||
|
}
|
@ -96,7 +96,7 @@ class Plugins
|
|||||||
{
|
{
|
||||||
$activePlugins = [];
|
$activePlugins = [];
|
||||||
foreach (static::$plugins as $plugin) {
|
foreach (static::$plugins as $plugin) {
|
||||||
if ($plugin->isActive()) {
|
if ($plugin->getStatus() === PluginStatus::ACTIVE) {
|
||||||
$activePlugins[] = $plugin;
|
$activePlugins[] = $plugin;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -111,7 +111,7 @@ class Plugins
|
|||||||
{
|
{
|
||||||
$pluginsWithPodcastSettings = [];
|
$pluginsWithPodcastSettings = [];
|
||||||
foreach (static::$plugins as $plugin) {
|
foreach (static::$plugins as $plugin) {
|
||||||
if (! $plugin->isActive()) {
|
if ($plugin->getStatus() !== PluginStatus::ACTIVE) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -132,7 +132,7 @@ class Plugins
|
|||||||
{
|
{
|
||||||
$pluginsWithEpisodeSettings = [];
|
$pluginsWithEpisodeSettings = [];
|
||||||
foreach (static::$plugins as $plugin) {
|
foreach (static::$plugins as $plugin) {
|
||||||
if (! $plugin->isActive()) {
|
if ($plugin->getStatus() !== PluginStatus::ACTIVE) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -183,7 +183,7 @@ class Plugins
|
|||||||
{
|
{
|
||||||
foreach (static::$plugins as $plugin) {
|
foreach (static::$plugins as $plugin) {
|
||||||
// only run hook on active plugins
|
// only run hook on active plugins
|
||||||
if (! $plugin->isActive()) {
|
if ($plugin->getStatus() !== PluginStatus::ACTIVE) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -282,7 +282,7 @@ class Plugins
|
|||||||
static::$pluginsByVendor[$vendor][] = $plugin;
|
static::$pluginsByVendor[$vendor][] = $plugin;
|
||||||
++static::$installedCount;
|
++static::$installedCount;
|
||||||
|
|
||||||
if ($plugin->isActive()) {
|
if ($plugin->getStatus() === PluginStatus::ACTIVE) {
|
||||||
++static::$activeCount;
|
++static::$activeCount;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -28,6 +28,7 @@ return [
|
|||||||
'deactivate' => 'Deactivate',
|
'deactivate' => 'Deactivate',
|
||||||
'active' => 'Active',
|
'active' => 'Active',
|
||||||
'inactive' => 'Inactive',
|
'inactive' => 'Inactive',
|
||||||
|
'invalid' => 'Invalid',
|
||||||
'uninstall' => 'Uninstall',
|
'uninstall' => 'Uninstall',
|
||||||
'keywords' => [
|
'keywords' => [
|
||||||
'podcasting20' => 'Podcasting 2.0',
|
'podcasting20' => 'Podcasting 2.0',
|
||||||
@ -40,4 +41,9 @@ return [
|
|||||||
'messages' => [
|
'messages' => [
|
||||||
'saveSettingsSuccess' => '{pluginName} settings were successfully saved!',
|
'saveSettingsSuccess' => '{pluginName} settings were successfully saved!',
|
||||||
],
|
],
|
||||||
|
'errors' => [
|
||||||
|
'manifestError' => 'Plugin manifest has errors',
|
||||||
|
'manifestMissing' => 'Plugin manifest "{manifestPath}" is missing.',
|
||||||
|
'manifestJsonInvalid' => 'Plugin manifest "{manifestPath}" is not a valid JSON.',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
@ -46,9 +46,9 @@ class Manifest extends ManifestObject
|
|||||||
'repository' => Repository::class,
|
'repository' => Repository::class,
|
||||||
];
|
];
|
||||||
|
|
||||||
protected string $name;
|
protected ?string $name = '???';
|
||||||
|
|
||||||
protected string $version;
|
protected ?string $version = 'X.Y.Z';
|
||||||
|
|
||||||
protected ?string $description = null;
|
protected ?string $description = null;
|
||||||
|
|
||||||
|
@ -17,16 +17,19 @@ abstract class ManifestObject
|
|||||||
protected const CASTS = [];
|
protected const CASTS = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var array<string,string>
|
* @var array<string,array<string,string>>
|
||||||
*/
|
*/
|
||||||
public static array $errors = [];
|
protected static array $errors = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param mixed[] $data
|
* @param mixed[] $data
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly array $data
|
protected readonly string $pluginKey,
|
||||||
|
private readonly array $data,
|
||||||
) {
|
) {
|
||||||
|
self::$errors[$pluginKey] = [];
|
||||||
|
|
||||||
$this->load();
|
$this->load();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -52,7 +55,11 @@ abstract class ManifestObject
|
|||||||
$validation->setRules($this::VALIDATION_RULES);
|
$validation->setRules($this::VALIDATION_RULES);
|
||||||
|
|
||||||
if (! $validation->run($this->data)) {
|
if (! $validation->run($this->data)) {
|
||||||
static::$errors = [...static::$errors, ...$validation->getErrors()];
|
foreach ($validation->getErrors() as $key => $message) {
|
||||||
|
$this->addError($key, $message);
|
||||||
|
}
|
||||||
|
|
||||||
|
$validation->reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($validation->getValidated() as $key => $value) {
|
foreach ($validation->getValidated() as $key => $value) {
|
||||||
@ -62,11 +69,11 @@ abstract class ManifestObject
|
|||||||
if (is_array($cast)) {
|
if (is_array($cast)) {
|
||||||
if (is_array($value)) {
|
if (is_array($value)) {
|
||||||
foreach ($value as $valueKey => $valueElement) {
|
foreach ($value as $valueKey => $valueElement) {
|
||||||
$value[$valueKey] = new $cast[0]($valueElement);
|
$value[$valueKey] = new $cast[0]($this->pluginKey, $valueElement);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$value = new $cast($value);
|
$value = new $cast($this->pluginKey, $value ?? []);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -77,8 +84,13 @@ abstract class ManifestObject
|
|||||||
/**
|
/**
|
||||||
* @return array<string,string>
|
* @return array<string,string>
|
||||||
*/
|
*/
|
||||||
public function getErrors(): array
|
public static function getPluginErrors(string $pluginKey): array
|
||||||
{
|
{
|
||||||
return $this->errors;
|
return self::$errors[$pluginKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function addError(string $errorKey, string $errorMessage): void
|
||||||
|
{
|
||||||
|
self::$errors[$this->pluginKey][$errorKey] = $errorMessage;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -35,7 +35,7 @@ class Person extends ManifestObject
|
|||||||
|
|
||||||
protected ?URI $url = null;
|
protected ?URI $url = null;
|
||||||
|
|
||||||
public function __construct(array|string $data)
|
public function __construct(string $pluginKey, array|string $data)
|
||||||
{
|
{
|
||||||
if (is_string($data)) {
|
if (is_string($data)) {
|
||||||
$result = preg_match(self::AUTHOR_STRING_PATTERN, $data, $matches);
|
$result = preg_match(self::AUTHOR_STRING_PATTERN, $data, $matches);
|
||||||
@ -51,6 +51,6 @@ class Person extends ManifestObject
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
parent::__construct($data);
|
parent::__construct($pluginKey, $data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,19 @@
|
|||||||
<article class="flex flex-col p-4 rounded-xl relative bg-elevated border-3 <?= $plugin->isActive() ? 'border-accent-base' : 'border-subtle' ?>">
|
<?php
|
||||||
|
|
||||||
|
use Modules\Plugins\Core\PluginStatus;
|
||||||
|
|
||||||
|
?>
|
||||||
|
<article class="flex flex-col p-4 rounded-xl relative bg-elevated border-3 <?= $plugin->getStatus() === PluginStatus::ACTIVE ? 'border-accent-base' : 'border-subtle' ?>">
|
||||||
<div class="self-end -mb-6">
|
<div class="self-end -mb-6">
|
||||||
<?php if($plugin->isActive()): ?>
|
<?php if($plugin->getStatus() === PluginStatus::ACTIVE): ?>
|
||||||
|
<?php // @icon('check-fill')?>
|
||||||
<x-Pill variant="success" icon="check-fill" class="lowercase" size="small"><?= lang('Plugins.active') ?></x-Pill>
|
<x-Pill variant="success" icon="check-fill" class="lowercase" size="small"><?= lang('Plugins.active') ?></x-Pill>
|
||||||
<?php else: ?>
|
<?php elseif($plugin->getStatus() === PluginStatus::INACTIVE): ?>
|
||||||
|
<?php // @icon('close-fill')?>
|
||||||
<x-Pill variant="default" icon="close-fill" class="lowercase" size="small"><?= lang('Plugins.inactive') ?></x-Pill>
|
<x-Pill variant="default" icon="close-fill" class="lowercase" size="small"><?= lang('Plugins.inactive') ?></x-Pill>
|
||||||
|
<?php elseif($plugin->getStatus() === PluginStatus::INVALID): ?>
|
||||||
|
<?php // @icon('alert-fill')?>
|
||||||
|
<x-Pill variant="warning" icon="alert-fill" class="lowercase" size="small"><?= lang('Plugins.invalid') ?></x-Pill>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
<img class="rounded-full min-w-16 max-w-16 aspect-square" src="<?= $plugin->getIconSrc() ?>">
|
<img class="rounded-full min-w-16 max-w-16 aspect-square" src="<?= $plugin->getIconSrc() ?>">
|
||||||
@ -30,12 +40,12 @@
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-x-2">
|
<div class="flex gap-x-2">
|
||||||
<?php if($plugin->isActive()): ?>
|
<?php if($plugin->getStatus() === PluginStatus::ACTIVE): ?>
|
||||||
<form class="flex justify-end" method="POST" action="<?= route_to('plugins-deactivate', $plugin->getVendor(), $plugin->getPackage()) ?>">
|
<form class="flex justify-end" method="POST" action="<?= route_to('plugins-deactivate', $plugin->getVendor(), $plugin->getPackage()) ?>">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<x-Button type="submit" variant="danger" size="small"><?= lang('Plugins.deactivate') ?></x-Button>
|
<x-Button type="submit" variant="danger" size="small"><?= lang('Plugins.deactivate') ?></x-Button>
|
||||||
</form>
|
</form>
|
||||||
<?php else: ?>
|
<?php elseif($plugin->getStatus() === PluginStatus::INACTIVE): ?>
|
||||||
<form class="flex flex-col items-end justify-end gap-2" method="POST" action="<?= route_to('plugins-activate', $plugin->getVendor(), $plugin->getPackage()) ?>">
|
<form class="flex flex-col items-end justify-end gap-2" method="POST" action="<?= route_to('plugins-activate', $plugin->getVendor(), $plugin->getPackage()) ?>">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<x-Button type="submit" variant="secondary" size="small"><?= lang('Plugins.activate') ?></x-Button>
|
<x-Button type="submit" variant="secondary" size="small"><?= lang('Plugins.activate') ?></x-Button>
|
||||||
|
@ -1,3 +1,7 @@
|
|||||||
|
<?php use Modules\Plugins\Core\PluginStatus;
|
||||||
|
|
||||||
|
?>
|
||||||
|
|
||||||
<?= $this->extend('_layout') ?>
|
<?= $this->extend('_layout') ?>
|
||||||
|
|
||||||
<?= $this->section('title') ?>
|
<?= $this->section('title') ?>
|
||||||
@ -9,15 +13,20 @@
|
|||||||
<?= $this->endSection() ?>
|
<?= $this->endSection() ?>
|
||||||
|
|
||||||
<?= $this->section('headerLeft') ?>
|
<?= $this->section('headerLeft') ?>
|
||||||
<?php if($plugin->isActive()): ?>
|
<?php if($plugin->getStatus() === PluginStatus::ACTIVE): ?>
|
||||||
|
<?php // @icon('check-fill')?>
|
||||||
<x-Pill variant="success" icon="check-fill" class="lowercase"><?= lang('Plugins.active') ?></x-Pill>
|
<x-Pill variant="success" icon="check-fill" class="lowercase"><?= lang('Plugins.active') ?></x-Pill>
|
||||||
<?php else: ?>
|
<?php elseif($plugin->getStatus() === PluginStatus::INACTIVE): ?>
|
||||||
|
<?php // @icon('close-fill')?>
|
||||||
<x-Pill variant="default" icon="close-fill" class="lowercase"><?= lang('Plugins.inactive') ?></x-Pill>
|
<x-Pill variant="default" icon="close-fill" class="lowercase"><?= lang('Plugins.inactive') ?></x-Pill>
|
||||||
|
<?php elseif($plugin->getStatus() === PluginStatus::INVALID): ?>
|
||||||
|
<?php // @icon('alert-fill')?>
|
||||||
|
<x-Pill variant="warning" icon="alert-fill" class="lowercase"><?= lang('Plugins.invalid') ?></x-Pill>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?= $this->endSection() ?>
|
<?= $this->endSection() ?>
|
||||||
|
|
||||||
<?= $this->section('headerRight') ?>
|
<?= $this->section('headerRight') ?>
|
||||||
<?php if($plugin->isActive()): ?>
|
<?php if($plugin->getStatus() === PluginStatus::ACTIVE): ?>
|
||||||
<form class="flex justify-end gap-x-2" method="POST" action="<?= route_to('plugins-deactivate', $plugin->getVendor(), $plugin->getPackage()) ?>">
|
<form class="flex justify-end gap-x-2" method="POST" action="<?= route_to('plugins-deactivate', $plugin->getVendor(), $plugin->getPackage()) ?>">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<x-Button type="submit" variant="danger"><?= lang('Plugins.deactivate') ?></x-Button>
|
<x-Button type="submit" variant="danger"><?= lang('Plugins.deactivate') ?></x-Button>
|
||||||
@ -26,7 +35,7 @@
|
|||||||
<x-Button class="ring-2 ring-inset ring-gray-600" iconLeft="equalizer-fill" uri="<?= route_to('plugins-settings-general', $plugin->getVendor(), $plugin->getPackage()) ?>"><?= lang('Plugins.settings') ?></x-Button>
|
<x-Button class="ring-2 ring-inset ring-gray-600" iconLeft="equalizer-fill" uri="<?= route_to('plugins-settings-general', $plugin->getVendor(), $plugin->getPackage()) ?>"><?= lang('Plugins.settings') ?></x-Button>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</form>
|
</form>
|
||||||
<?php else: ?>
|
<?php elseif($plugin->getStatus() === PluginStatus::INVALID): ?>
|
||||||
<form class="flex justify-end gap-x-2" method="POST" action="<?= route_to('plugins-activate', $plugin->getVendor(), $plugin->getPackage()) ?>">
|
<form class="flex justify-end gap-x-2" method="POST" action="<?= route_to('plugins-activate', $plugin->getVendor(), $plugin->getPackage()) ?>">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<x-Button type="submit" variant="secondary"><?= lang('Plugins.activate') ?></x-Button>
|
<x-Button type="submit" variant="secondary"><?= lang('Plugins.activate') ?></x-Button>
|
||||||
@ -39,6 +48,16 @@
|
|||||||
<?= $this->endSection() ?>
|
<?= $this->endSection() ?>
|
||||||
|
|
||||||
<?= $this->section('content') ?>
|
<?= $this->section('content') ?>
|
||||||
|
<?php if ($plugin->getStatus() === PluginStatus::INVALID): ?>
|
||||||
|
<x-Alert title="<?= lang('Plugins.errors.manifestError') ?>" variant="warning" class="mb-12">
|
||||||
|
<ul>
|
||||||
|
<?php foreach ($plugin->getErrors() as $key => $error): ?>
|
||||||
|
<li><?= $error ?></li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
</x-Alert>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<div class="flex flex-col items-start justify-center gap-8 mx-auto xl:flex-row-reverse">
|
<div class="flex flex-col items-start justify-center gap-8 mx-auto xl:flex-row-reverse">
|
||||||
<aside class="w-full pb-8 border-b xl:sticky xl:max-w-xs top-28 border-subtle xl:border-none">
|
<aside class="w-full pb-8 border-b xl:sticky xl:max-w-xs top-28 border-subtle xl:border-none">
|
||||||
<h2 class="mb-2 text-2xl font-bold font-display"><?= lang('Plugins.about') ?></h2>
|
<h2 class="mb-2 text-2xl font-bold font-display"><?= lang('Plugins.about') ?></h2>
|
||||||
@ -54,6 +73,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<ul class="flex flex-col gap-2 mt-4">
|
<ul class="flex flex-col gap-2 mt-4">
|
||||||
|
<li class="inline-flex items-center font-mono text-sm gap-x-2"><?= icon('box-2-line', [
|
||||||
|
'class' => 'text-gray-500 text-xl',
|
||||||
|
]) . $plugin->getVersion() ?></li>
|
||||||
<?php if ($plugin->getRepository()): ?>
|
<?php if ($plugin->getRepository()): ?>
|
||||||
<li><a href="<?= $plugin->getRepository()->url ?>" class="inline-flex items-center text-sm gap-x-2 hover:underline" target="_blank" rel="noopener noreferrer"><?= icon('git-repository-fill', [
|
<li><a href="<?= $plugin->getRepository()->url ?>" class="inline-flex items-center text-sm gap-x-2 hover:underline" target="_blank" rel="noopener noreferrer"><?= icon('git-repository-fill', [
|
||||||
'class' => 'text-gray-500 text-xl',
|
'class' => 'text-gray-500 text-xl',
|
||||||
|
Loading…
Reference in New Issue
Block a user