refactor(plugins): redefine plugins folder structure to vendor/package

This commit is contained in:
Yassine Doghri 2024-05-05 13:10:59 +00:00
parent 1ff91a8546
commit d321d0f93c
8 changed files with 195 additions and 77 deletions

View File

@ -114,14 +114,28 @@ class Autoload extends AutoloadConfig
public function __construct() public function __construct()
{ {
// load plugins namespaces // load plugins namespaces
$pluginsPaths = glob(PLUGINS_PATH . '*', GLOB_ONLYDIR | GLOB_NOSORT); $pluginsPaths = glob(PLUGINS_PATH . '*/*', GLOB_ONLYDIR | GLOB_NOSORT);
if (! $pluginsPaths) { if (! $pluginsPaths) {
$pluginsPaths = []; $pluginsPaths = [];
} }
foreach ($pluginsPaths as $pluginPath) { foreach ($pluginsPaths as $pluginPath) {
$this->psr4[sprintf('Plugins\%s', basename($pluginPath))] = $pluginPath; $vendor = basename(dirname($pluginPath));
$package = basename($pluginPath);
// validate plugin pattern
if (preg_match('~' . PLUGINS_KEY_PATTERN . '~', $vendor . '/' . $package) === false) {
continue;
}
$pluginNamespace = 'Plugins\\' . str_replace(
' ',
'',
ucwords(str_replace(['-', '_', '.'], ' ', $vendor . '\\' . $package))
);
$this->psr4[$pluginNamespace] = $pluginPath;
} }
parent::__construct(); parent::__construct();

View File

@ -38,6 +38,9 @@ defined('APP_NAMESPACE') || define('APP_NAMESPACE', 'App');
defined('PLUGINS_PATH') || defined('PLUGINS_PATH') ||
define('PLUGINS_PATH', ROOTPATH . 'plugins' . DIRECTORY_SEPARATOR); define('PLUGINS_PATH', ROOTPATH . 'plugins' . DIRECTORY_SEPARATOR);
defined('PLUGINS_KEY_PATTERN') ||
define('PLUGINS_KEY_PATTERN', '[a-z0-9]([_.-]?[a-z0-9]+)*\/[a-z0-9]([_.-]?[a-z0-9]+)*');
/* /*
| -------------------------------------------------------------------------- | --------------------------------------------------------------------------
| Composer Path | Composer Path

View File

@ -11,6 +11,7 @@ use CodeIgniter\I18n\Time;
use RuntimeException; use RuntimeException;
/** /**
* @property string $key
* @property string $name * @property string $name
* @property string $description * @property string $description
* @property string $version * @property string $version
@ -29,12 +30,13 @@ abstract class BasePlugin implements PluginInterface
protected bool $active; protected bool $active;
public function __construct( public function __construct(
protected string $key, protected string $vendor,
protected string $filePath protected string $package,
protected string $directory
) { ) {
$pluginDirectory = dirname($filePath); $this->key = sprintf('%s/%s', $vendor, $package);
$manifest = $this->loadManifest($pluginDirectory . '/manifest.json'); $manifest = $this->loadManifest($directory . '/manifest.json');
foreach ($manifest as $key => $value) { foreach ($manifest as $key => $value) {
$this->{$key} = $value; $this->{$key} = $value;
@ -43,7 +45,7 @@ abstract class BasePlugin implements PluginInterface
// check that plugin is active // check that plugin is active
$this->active = get_plugin_option($this->key, 'active') ?? false; $this->active = get_plugin_option($this->key, 'active') ?? false;
$this->iconSrc = $this->loadIcon($pluginDirectory . '/icon.svg'); $this->iconSrc = $this->loadIcon($directory . '/icon.svg');
} }
/** /**
@ -88,6 +90,16 @@ abstract class BasePlugin implements PluginInterface
return $this->key; return $this->key;
} }
final public function getVendor(): string
{
return $this->vendor;
}
final public function getPackage(): string
{
return $this->package;
}
final public function getName(): string final public function getName(): string
{ {
$key = sprintf('Plugin.%s.name', $this->key); $key = sprintf('Plugin.%s.name', $this->key);

View File

@ -6,6 +6,7 @@ namespace Modules\Plugins\Commands;
use CodeIgniter\CLI\BaseCommand; use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI; use CodeIgniter\CLI\CLI;
use Modules\Plugins\Plugins;
class UninstallPlugin extends BaseCommand class UninstallPlugin extends BaseCommand
{ {
@ -51,18 +52,20 @@ class UninstallPlugin extends BaseCommand
*/ */
public function run(array $pluginKeys): int public function run(array $pluginKeys): int
{ {
$validation = service('validation'); /** @var Plugins $plugins */
$plugins = service('plugins');
/** @var list<string> $errors */ /** @var list<string> $errors */
$errors = []; $errors = [];
foreach ($pluginKeys as $pluginKey) { foreach ($pluginKeys as $pluginKey) {
// TODO: change validation of pluginKey $plugin = $plugins->getPluginByKey($pluginKey);
if (! $validation->check($pluginKey, 'required')) {
$errors = [...$errors, ...$validation->getErrors()]; if ($plugin === null) {
$errors[] = sprintf('Plugin %s was not found.', $pluginKey);
continue; continue;
} }
if (! service('plugins')->uninstall($pluginKey)) { if (! $plugins->uninstall($plugin)) {
$errors[] = sprintf('Something happened when removing %s', $pluginKey); $errors[] = sprintf('Something happened when removing %s', $pluginKey);
} }
} }

View File

@ -5,6 +5,9 @@ declare(strict_types=1);
use CodeIgniter\Router\RouteCollection; use CodeIgniter\Router\RouteCollection;
/** @var RouteCollection $routes */ /** @var RouteCollection $routes */
$routes->addPlaceholder('pluginVendor', '[a-z0-9]([_.-]?[a-z0-9]+)*');
$routes->addPlaceholder('pluginKey', PLUGINS_KEY_PATTERN);
$routes->group( $routes->group(
config('Admin') config('Admin')
->gateway, ->gateway,
@ -17,44 +20,50 @@ $routes->group(
'as' => 'plugins-installed', 'as' => 'plugins-installed',
'filter' => 'permission:plugins.manage', 'filter' => 'permission:plugins.manage',
]); ]);
$routes->get('(:segment)', 'PluginController::generalSettings/$1', [ $routes->get('(:pluginVendor)', 'PluginController::vendor/$1', [
'as' => 'plugins-general-settings', 'as' => 'plugins-vendor',
'filter' => 'permission:plugins.manage',
]);
$routes->post('(:segment)', 'PluginController::generalSettingsAction/$1', [
'as' => 'plugins-general-settings-action',
'filter' => 'permission:plugins.manage',
]);
$routes->post('(:segment)/activate', 'PluginController::activate/$1', [
'as' => 'plugins-activate',
'filter' => 'permission:plugins.manage',
]);
$routes->post('(:segment)/deactivate', 'PluginController::deactivate/$1', [
'as' => 'plugins-deactivate',
'filter' => 'permission:plugins.manage',
]);
// TODO: change to delete
$routes->get('(:segment)/uninstall', 'PluginController::uninstall/$1', [
'as' => 'plugins-uninstall',
'filter' => 'permission:plugins.manage', 'filter' => 'permission:plugins.manage',
]); ]);
$routes->group('(:pluginKey)', static function ($routes): void {
$routes->get('/', 'PluginController::generalSettings/$1/$2', [
'as' => 'plugins-general-settings',
'filter' => 'permission:plugins.manage',
]);
$routes->post('/', 'PluginController::generalSettingsAction/$1/$2', [
'as' => 'plugins-general-settings-action',
'filter' => 'permission:plugins.manage',
]);
$routes->post('activate', 'PluginController::activate/$1/$2', [
'as' => 'plugins-activate',
'filter' => 'permission:plugins.manage',
]);
$routes->post('deactivate', 'PluginController::deactivate/$1/$2', [
'as' => 'plugins-deactivate',
'filter' => 'permission:plugins.manage',
]);
// TODO: change to delete
$routes->get('uninstall', 'PluginController::uninstall/$1/$2', [
'as' => 'plugins-uninstall',
'filter' => 'permission:plugins.manage',
]);
});
}); });
$routes->group('podcasts/(:num)/plugins', static function ($routes): void { $routes->group('podcasts/(:num)/plugins', static function ($routes): void {
$routes->get('(:segment)', 'PluginController::podcastSettings/$1/$2', [ $routes->get('(:pluginKey)', 'PluginController::podcastSettings/$1/$2/$3', [
'as' => 'plugins-podcast-settings', 'as' => 'plugins-podcast-settings',
'filter' => 'permission:podcast#.edit', 'filter' => 'permission:podcast#.edit',
]); ]);
$routes->post('(:segment)', 'PluginController::podcastSettingsAction/$1/$2', [ $routes->post('(:pluginKey)', 'PluginController::podcastSettingsAction/$1/$2/$3', [
'as' => 'plugins-podcast-settings-action', 'as' => 'plugins-podcast-settings-action',
'filter' => 'permission:podcast#.edit', 'filter' => 'permission:podcast#.edit',
]); ]);
}); });
$routes->group('podcasts/(:num)/episodes/(:num)/plugins', static function ($routes): void { $routes->group('podcasts/(:num)/episodes/(:num)/plugins', static function ($routes): void {
$routes->get('(:segment)', 'PluginController::episodeSettings/$1/$2/$3', [ $routes->get('(:pluginKey)', 'PluginController::episodeSettings/$1/$2/$3/$4', [
'as' => 'plugins-episode-settings', 'as' => 'plugins-episode-settings',
'filter' => 'permission:podcast#.edit', 'filter' => 'permission:podcast#.edit',
]); ]);
$routes->post('(:segment)', 'PluginController::episodeSettingsAction/$1/$2/$3', [ $routes->post('(:pluginKey)', 'PluginController::episodeSettingsAction/$1/$2/$3/$4', [
'as' => 'plugins-episode-settings-action', 'as' => 'plugins-episode-settings-action',
'filter' => 'permission:podcast#.edit', 'filter' => 'permission:podcast#.edit',
]); ]);

View File

@ -35,12 +35,25 @@ class PluginController extends BaseController
]); ]);
} }
public function generalSettings(string $pluginKey): string public function vendor(string $vendor): string
{ {
/** @var Plugins $plugins */ /** @var Plugins $plugins */
$plugins = service('plugins'); $plugins = service('plugins');
$plugin = $plugins->getPlugin($pluginKey); $vendorPlugins = $plugins->getVendorPlugins($vendor);
return view('plugins/installed', [
'total' => count($vendorPlugins),
'plugins' => $vendorPlugins,
'pager_links' => '',
]);
}
public function generalSettings(string $vendor, string $package): string
{
/** @var Plugins $plugins */
$plugins = service('plugins');
$plugin = $plugins->getPlugin($vendor, $package);
if ($plugin === null) { if ($plugin === null) {
throw PageNotFoundException::forPageNotFound(); throw PageNotFoundException::forPageNotFound();
@ -52,12 +65,12 @@ class PluginController extends BaseController
]); ]);
} }
public function generalSettingsAction(string $pluginKey): RedirectResponse public function generalSettingsAction(string $vendor, string $package): RedirectResponse
{ {
/** @var Plugins $plugins */ /** @var Plugins $plugins */
$plugins = service('plugins'); $plugins = service('plugins');
$plugin = $plugins->getPlugin($pluginKey); $plugin = $plugins->getPlugin($vendor, $package);
if ($plugin === null) { if ($plugin === null) {
throw PageNotFoundException::forPageNotFound(); throw PageNotFoundException::forPageNotFound();
@ -66,7 +79,7 @@ class PluginController extends BaseController
foreach ($plugin->settings['general'] as $option) { foreach ($plugin->settings['general'] as $option) {
$optionKey = $option['key']; $optionKey = $option['key'];
$optionValue = $this->request->getPost($optionKey); $optionValue = $this->request->getPost($optionKey);
$plugins->setOption($pluginKey, $optionKey, $optionValue); $plugins->setOption($plugin, $optionKey, $optionValue);
} }
return redirect()->back() return redirect()->back()
@ -75,7 +88,7 @@ class PluginController extends BaseController
])); ]));
} }
public function podcastSettings(string $podcastId, string $pluginKey): string public function podcastSettings(string $podcastId, string $vendor, string $package): string
{ {
$podcast = (new PodcastModel())->getPodcastById((int) $podcastId); $podcast = (new PodcastModel())->getPodcastById((int) $podcastId);
@ -86,7 +99,7 @@ class PluginController extends BaseController
/** @var Plugins $plugins */ /** @var Plugins $plugins */
$plugins = service('plugins'); $plugins = service('plugins');
$plugin = $plugins->getPlugin($pluginKey); $plugin = $plugins->getPlugin($vendor, $package);
if ($plugin === null) { if ($plugin === null) {
throw PageNotFoundException::forPageNotFound(); throw PageNotFoundException::forPageNotFound();
@ -102,12 +115,12 @@ class PluginController extends BaseController
]); ]);
} }
public function podcastSettingsAction(string $podcastId, string $pluginKey): RedirectResponse public function podcastSettingsAction(string $podcastId, string $vendor, string $package): RedirectResponse
{ {
/** @var Plugins $plugins */ /** @var Plugins $plugins */
$plugins = service('plugins'); $plugins = service('plugins');
$plugin = $plugins->getPlugin($pluginKey); $plugin = $plugins->getPlugin($vendor, $package);
if ($plugin === null) { if ($plugin === null) {
throw PageNotFoundException::forPageNotFound(); throw PageNotFoundException::forPageNotFound();
@ -116,7 +129,7 @@ class PluginController extends BaseController
foreach ($plugin->settings['podcast'] as $setting) { foreach ($plugin->settings['podcast'] as $setting) {
$settingKey = $setting['key']; $settingKey = $setting['key'];
$settingValue = $this->request->getPost($settingKey); $settingValue = $this->request->getPost($settingKey);
$plugins->setOption($pluginKey, $settingKey, $settingValue, ['podcast', (int) $podcastId]); $plugins->setOption($plugin, $settingKey, $settingValue, ['podcast', (int) $podcastId]);
} }
return redirect()->back() return redirect()->back()
@ -125,7 +138,7 @@ class PluginController extends BaseController
])); ]));
} }
public function episodeSettings(string $podcastId, string $episodeId, string $pluginKey): string public function episodeSettings(string $podcastId, string $episodeId, string $vendor, string $package): string
{ {
$episode = (new EpisodeModel())->getEpisodeById((int) $episodeId); $episode = (new EpisodeModel())->getEpisodeById((int) $episodeId);
@ -136,7 +149,7 @@ class PluginController extends BaseController
/** @var Plugins $plugins */ /** @var Plugins $plugins */
$plugins = service('plugins'); $plugins = service('plugins');
$plugin = $plugins->getPlugin($pluginKey); $plugin = $plugins->getPlugin($vendor, $package);
if ($plugin === null) { if ($plugin === null) {
throw PageNotFoundException::forPageNotFound(); throw PageNotFoundException::forPageNotFound();
@ -154,12 +167,16 @@ class PluginController extends BaseController
]); ]);
} }
public function episodeSettingsAction(string $podcastId, string $episodeId, string $pluginKey): RedirectResponse public function episodeSettingsAction(
{ string $podcastId,
string $episodeId,
string $vendor,
string $package
): RedirectResponse {
/** @var Plugins $plugins */ /** @var Plugins $plugins */
$plugins = service('plugins'); $plugins = service('plugins');
$plugin = $plugins->getPlugin($pluginKey); $plugin = $plugins->getPlugin($vendor, $package);
if ($plugin === null) { if ($plugin === null) {
throw PageNotFoundException::forPageNotFound(); throw PageNotFoundException::forPageNotFound();
@ -168,7 +185,7 @@ class PluginController extends BaseController
foreach ($plugin->settings['episode'] as $setting) { foreach ($plugin->settings['episode'] as $setting) {
$settingKey = $setting['key']; $settingKey = $setting['key'];
$settingValue = $this->request->getPost($settingKey); $settingValue = $this->request->getPost($settingKey);
$plugins->setOption($pluginKey, $settingKey, $settingValue, ['episode', (int) $episodeId]); $plugins->setOption($plugin, $settingKey, $settingValue, ['episode', (int) $episodeId]);
} }
return redirect()->back() return redirect()->back()
@ -177,23 +194,50 @@ class PluginController extends BaseController
])); ]));
} }
public function activate(string $pluginKey): RedirectResponse public function activate(string $vendor, string $package): RedirectResponse
{ {
service('plugins')->activate($pluginKey); /** @var Plugins $plugins */
$plugins = service('plugins');
$plugin = $plugins->getPlugin($vendor, $package);
if ($plugin === null) {
throw PageNotFoundException::forPageNotFound();
}
$plugins->activate($plugin);
return redirect()->back(); return redirect()->back();
} }
public function deactivate(string $pluginKey): RedirectResponse public function deactivate(string $vendor, string $package): RedirectResponse
{ {
service('plugins')->deactivate($pluginKey); /** @var Plugins $plugins */
$plugins = service('plugins');
$plugin = $plugins->getPlugin($vendor, $package);
if ($plugin === null) {
throw PageNotFoundException::forPageNotFound();
}
$plugins->deactivate($plugin);
return redirect()->back(); return redirect()->back();
} }
public function uninstall(string $pluginKey): RedirectResponse public function uninstall(string $vendor, string $package): RedirectResponse
{ {
service('plugins')->uninstall($pluginKey); /** @var Plugins $plugins */
$plugins = service('plugins');
$plugin = $plugins->getPlugin($vendor, $package);
if ($plugin === null) {
throw PageNotFoundException::forPageNotFound();
}
$plugins->uninstall($plugin);
return redirect()->back(); return redirect()->back();
} }

View File

@ -28,6 +28,11 @@ class Plugins
*/ */
protected static array $plugins = []; protected static array $plugins = [];
/**
* @var array<string,BasePlugin[]>
*/
protected static array $pluginsByVendor = [];
protected static int $installedCount = 0; protected static int $installedCount = 0;
public function __construct() public function __construct()
@ -100,10 +105,18 @@ class Plugins
return $pluginsWithEpisodeSettings; return $pluginsWithEpisodeSettings;
} }
public function getPlugin(string $key): ?BasePlugin /**
* @return array<BasePlugin>
*/
public function getVendorPlugins(string $vendor): array
{ {
foreach (static::$plugins as $plugin) { return static::$pluginsByVendor[$vendor] ?? [];
if ($plugin->getKey() === $key) { }
public function getPlugin(string $vendor, string $package): ?BasePlugin
{
foreach ($this->getVendorPlugins($vendor) as $plugin) {
if ($plugin->getKey() === $vendor . '/' . $package) {
return $plugin; return $plugin;
} }
} }
@ -111,6 +124,16 @@ class Plugins
return null; return null;
} }
public function getPluginByKey(string $key): ?BasePlugin
{
if (! str_contains('/', $key)) {
return null;
}
$keyArray = explode('/', $key);
return $this->getPlugin($keyArray[0], $keyArray[1]);
}
/** /**
* @param value-of<static::HOOKS> $name * @param value-of<static::HOOKS> $name
* @param array<mixed> $arguments * @param array<mixed> $arguments
@ -131,22 +154,22 @@ class Plugins
} }
} }
public function activate(string $pluginKey): void public function activate(BasePlugin $plugin): void
{ {
set_plugin_option($pluginKey, 'active', true); set_plugin_option($plugin->getKey(), 'active', true);
} }
public function deactivate(string $pluginKey): void public function deactivate(BasePlugin $plugin): void
{ {
set_plugin_option($pluginKey, 'active', false); set_plugin_option($plugin->getKey(), 'active', false);
} }
/** /**
* @param ?array{'podcast'|'episode',int} $additionalContext * @param ?array{'podcast'|'episode',int} $additionalContext
*/ */
public function setOption(string $pluginKey, string $name, mixed $value, array $additionalContext = null): void public function setOption(BasePlugin $plugin, string $name, mixed $value, array $additionalContext = null): void
{ {
set_plugin_option($pluginKey, $name, $value, $additionalContext); set_plugin_option($plugin->getKey(), $name, $value, $additionalContext);
} }
public function getInstalledCount(): int public function getInstalledCount(): int
@ -154,7 +177,7 @@ class Plugins
return static::$installedCount; return static::$installedCount;
} }
public function uninstall(string $pluginKey): bool public function uninstall(BasePlugin $plugin): bool
{ {
// remove all settings data // remove all settings data
$db = Database::connect(); $db = Database::connect();
@ -162,7 +185,7 @@ class Plugins
$db->transStart(); $db->transStart();
$builder->where('class', self::class); $builder->where('class', self::class);
$builder->like('context', sprintf('plugin:%s', $pluginKey . '%')); $builder->like('context', sprintf('plugin:%s', $plugin->getKey() . '%'));
if (! $builder->delete()) { if (! $builder->delete()) {
$db->transRollback(); $db->transRollback();
@ -170,7 +193,7 @@ class Plugins
} }
// delete plugin folder from PLUGINS_PATH // delete plugin folder from PLUGINS_PATH
$pluginFolder = PLUGINS_PATH . $pluginKey; $pluginFolder = PLUGINS_PATH . $plugin->getKey();
$rmdirResult = $this->rrmdir($pluginFolder); $rmdirResult = $this->rrmdir($pluginFolder);
$transResult = $db->transCommit(); $transResult = $db->transCommit();
@ -181,27 +204,36 @@ class Plugins
protected function registerPlugins(): void protected function registerPlugins(): void
{ {
// search for plugins in plugins folder // search for plugins in plugins folder
// TODO: only get directories? Should be organized as author/repo? $pluginsDirectories = glob(PLUGINS_PATH . '*/*', GLOB_ONLYDIR);
$pluginsFiles = glob(PLUGINS_PATH . '**/Plugin.php');
if (! $pluginsFiles) { if ($pluginsDirectories === false || $pluginsDirectories === []) {
return; return;
} }
$locator = service('locator'); $locator = service('locator');
foreach ($pluginsFiles as $file) { foreach ($pluginsDirectories as $pluginDirectory) {
$className = $locator->findQualifiedNameFromPath($file); $vendor = basename(dirname($pluginDirectory));
$package = basename($pluginDirectory);
if (preg_match('~' . PLUGINS_KEY_PATTERN . '~', $vendor . '/' . $package) === false) {
continue;
}
$pluginFile = $pluginDirectory . DIRECTORY_SEPARATOR . 'Plugin.php';
$className = $locator->findQualifiedNameFromPath($pluginFile);
if ($className === false) { if ($className === false) {
continue; continue;
} }
$plugin = new $className(basename(dirname($file)), $file); $plugin = new $className($vendor, $package, $pluginDirectory);
if (! $plugin instanceof BasePlugin) { if (! $plugin instanceof BasePlugin) {
continue; continue;
} }
static::$plugins[] = $plugin; static::$plugins[] = $plugin;
static::$pluginsByVendor[$vendor][] = $plugin;
++static::$installedCount; ++static::$installedCount;
} }
} }

View File

@ -6,9 +6,10 @@
]) ?></IconButton> ]) ?></IconButton>
<?php endif; ?> <?php endif; ?>
<img class="rounded-full min-w-16 max-w-16 aspect-square" src="<?= $plugin->iconSrc ?>"> <img class="rounded-full min-w-16 max-w-16 aspect-square" src="<?= $plugin->iconSrc ?>">
<div class="flex flex-col mt-2"> <div class="flex flex-col items-start mt-2">
<h2 class="flex items-center text-xl font-bold font-display gap-x-2"><?= $plugin->getName() ?><span class="px-1 font-mono text-xs rounded-full bg-subtle"><?= $plugin->version ?></span></h2> <h2 class="flex items-center text-xl font-bold font-display gap-x-2"><?= $plugin->getName() ?><span class="px-1 font-mono text-xs rounded-full bg-subtle"><?= $plugin->version ?></span></h2>
<p class="text-gray-600"><?= $plugin->getDescription() ?></p> <p class="font-mono text-xs 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>/<?= $plugin->getPackage() ?></p>
<p class="mt-2 text-gray-600"><?= $plugin->getDescription() ?></p>
</div> </div>
<footer class="flex items-center justify-between mt-4"> <footer class="flex items-center justify-between mt-4">
<a href="<?= $plugin->website ?>" class="inline-flex items-center text-sm font-semibold underline hover:no-underline gap-x-1" target="_blank" rel="noopener noreferrer"><?= icon('link', [ <a href="<?= $plugin->website ?>" class="inline-flex items-center text-sm font-semibold underline hover:no-underline gap-x-1" target="_blank" rel="noopener noreferrer"><?= icon('link', [