init with kirby, vue and pagedjs interactive

This commit is contained in:
isUnknown 2025-11-24 14:01:48 +01:00
commit dc0ae26464
968 changed files with 211706 additions and 0 deletions

View file

@ -0,0 +1,124 @@
<?php
namespace Kirby\Plugin;
use Kirby\Filesystem\F;
use Stringable;
/**
* Representing a plugin asset with methods
* to manage the asset file between the plugin
* and media folder
*
* @package Kirby Plugin
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Asset implements Stringable
{
public function __construct(
protected string $path,
protected string $root,
protected Plugin $plugin
) {
}
public function extension(): string
{
return F::extension($this->path());
}
public function filename(): string
{
return F::filename($this->path());
}
/**
* Create a unique media hash
*/
public function mediaHash(): string
{
return crc32($this->filename()) . '-' . $this->modified();
}
/**
* Absolute path to the asset file in the media folder
*/
public function mediaRoot(): string
{
return $this->plugin()->mediaRoot() . '/' . $this->mediaHash() . '/' . $this->path();
}
/**
* Public accessible url path for the asset
*/
public function mediaUrl(): string
{
return $this->plugin()->mediaUrl() . '/' . $this->mediaHash() . '/' . $this->path();
}
/**
* Timestamp when asset file was last modified
*/
public function modified(): int|false
{
return F::modified($this->root());
}
public function path(): string
{
return $this->path;
}
public function plugin(): Plugin
{
return $this->plugin;
}
/**
* Publishes the asset file to the plugin's media folder
* by creating a symlink
*/
public function publish(): void
{
F::link($this->root(), $this->mediaRoot(), 'symlink');
}
/**
* @internal
* @since 4.0.0
* @deprecated 4.0.0
* @codeCoverageIgnore
*/
public function publishAt(string $path): void
{
F::link(
$this->root(),
$this->plugin()->mediaRoot() . '/' . $path,
'symlink'
);
}
public function root(): string
{
return $this->root;
}
/**
* @see self::mediaUrl()
*/
public function url(): string
{
return $this->mediaUrl();
}
/**
* @see self::url()
*/
public function __toString(): string
{
return $this->url();
}
}

View file

@ -0,0 +1,188 @@
<?php
namespace Kirby\Plugin;
use Closure;
use Kirby\Cms\App;
use Kirby\Cms\Collection;
use Kirby\Filesystem\Dir;
use Kirby\Filesystem\F;
use Kirby\Http\Response;
use Kirby\Toolkit\Str;
/**
* Plugin assets are automatically copied/linked
* to the media folder, to make them publicly
* available. This class handles the magic around that.
*
* @package Kirby Plugin
* @author Bastian Allgeier <bastian@getkirby.com>
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*
* @extends \Kirby\Cms\Collection<\Kirby\Plugin\Asset>
*/
class Assets extends Collection
{
/**
* Clean old/deprecated assets on every resolve
*/
public static function clean(string $pluginName): void
{
if ($plugin = App::instance()->plugin($pluginName)) {
$media = $plugin->mediaRoot();
$assets = $plugin->assets();
// get all media files
$files = Dir::index($media, true);
// get all active assets' paths from the plugin
$active = $assets->values(
function ($asset) {
$path = $asset->mediaHash() . '/' . $asset->path();
$paths = [];
$parts = explode('/', $path);
// collect all path segments
// (e.g. foo/, foo/bar/, foo/bar/baz.css) for the asset
for ($i = 1, $max = count($parts); $i <= $max; $i++) {
$paths[] = implode('/', array_slice($parts, 0, $i));
// TODO: remove when media hash is enforced as mandatory
$paths[] = implode('/', array_slice($parts, 1, $i));
}
return $paths;
}
);
// flatten the array and remove duplicates
$active = array_unique(array_merge(...array_values($active)));
// get outdated media files by comparing all
// files in the media folder against the set of asset paths
$stale = array_diff($files, $active);
foreach ($stale as $file) {
$root = $media . '/' . $file;
if (is_file($root) === true) {
F::remove($root);
} else {
Dir::remove($root);
}
}
}
}
/**
* Filters assets collection by CSS files
*/
public function css(): static
{
return $this->filter(fn ($asset) => $asset->extension() === 'css');
}
/**
* Creates a new collection for the plugin's assets
* by considering the plugin's `asset` extension
* (and `assets` directory as fallback)
*/
public static function factory(Plugin $plugin): static
{
// get assets defined in the plugin extension
if ($assets = $plugin->extends()['assets'] ?? null) {
if ($assets instanceof Closure) {
$assets = $assets();
}
// normalize array: use relative path as
// key when no key is defined
foreach ($assets as $key => $root) {
if (is_int($key) === true) {
unset($assets[$key]);
$path = Str::after($root, $plugin->root() . '/');
$assets[$path] = $root;
}
}
}
// fallback: if no assets are defined in the plugin extension,
// use all files in the plugin's `assets` directory
if ($assets === null) {
$assets = [];
$root = $plugin->root() . '/assets';
foreach (Dir::index($root, true) as $path) {
if (is_file($root . '/' . $path) === true) {
$assets[$path] = $root . '/' . $path;
}
}
}
$collection = new static([], $plugin);
foreach ($assets as $path => $root) {
$collection->data[$path] = new Asset($path, $root, $plugin);
}
return $collection;
}
/**
* Filters assets collection by JavaScript files
*/
public function js(): static
{
return $this->filter(fn ($asset) => $asset->extension() === 'js');
}
public function plugin(): Plugin
{
return $this->parent;
}
/**
* Create a symlink for a plugin asset and
* return the public URL
*/
public static function resolve(
string $pluginName,
string $hash,
string $path
): Response|null {
if ($plugin = App::instance()->plugin($pluginName)) {
// do some spring cleaning for older files
static::clean($pluginName);
// @codeCoverageIgnoreStart
// TODO: deprecated media URL without hash
if (empty($hash) === true) {
$asset = $plugin->asset($path);
$asset->publishAt($path);
return Response::file($asset->root());
}
// TODO: deprecated media URL with hash (but path)
if ($asset = $plugin->asset($hash . '/' . $path)) {
$asset->publishAt($hash . '/' . $path);
return Response::file($asset->root());
}
// @codeCoverageIgnoreEnd
if ($asset = $plugin->asset($path)) {
if ($asset->mediaHash() === $hash) {
// create a symlink if possible
$asset->publish();
// return the file response
return Response::file($asset->root());
}
}
}
return null;
}
}

View file

@ -0,0 +1,112 @@
<?php
namespace Kirby\Plugin;
use Closure;
use Stringable;
/**
* Represents the license of a plugin.
* Used to display the license in the Panel system view
*
* @package Kirby Plugin
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
*/
class License implements Stringable
{
protected LicenseStatus $status;
public function __construct(
protected Plugin $plugin,
protected string $name,
protected string|null $link = null,
LicenseStatus|null $status = null
) {
$this->status = $status ?? LicenseStatus::from('unknown');
}
/**
* Returns the string representation of the license
*/
public function __toString(): string
{
return $this->name();
}
/**
* Creates a license instance from a given value
*/
public static function from(
Plugin $plugin,
Closure|array|string|null $license
): static {
if ($license instanceof Closure) {
return $license($plugin);
}
if (is_array($license)) {
return new static(
plugin: $plugin,
name: $license['name'] ?? '',
link: $license['link'] ?? null,
status: LicenseStatus::from($license['status'] ?? 'active')
);
}
if ($license === null || $license === '-') {
return new static(
plugin: $plugin,
name: '-',
status: LicenseStatus::from('unknown')
);
}
return new static(
plugin: $plugin,
name: $license,
status: LicenseStatus::from('active')
);
}
/**
* Get the license link. This can be the
* license terms or a link to a shop to
* purchase a license.
*/
public function link(): string|null
{
return $this->link;
}
/**
* Get the license name
*/
public function name(): string
{
return $this->name;
}
/**
* Get the license status
*/
public function status(): LicenseStatus
{
return $this->status;
}
/**
* Returns the license information as an array
*/
public function toArray(): array
{
return [
'link' => $this->link(),
'name' => $this->name(),
'status' => $this->status()->toArray()
];
}
}

View file

@ -0,0 +1,135 @@
<?php
namespace Kirby\Plugin;
use Kirby\Cms\LicenseStatus as SystemLicenseStatus;
use Stringable;
/**
* Represents the license status of a plugin.
* Used to display the status in the Panel system view
*
* @package Kirby Plugin
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
*/
class LicenseStatus implements Stringable
{
public function __construct(
protected string $value,
protected string $icon,
protected string $label,
protected string|null $link = null,
protected string|null $dialog = null,
protected string|null $drawer = null,
protected string|null $theme = null
) {
}
/**
* Returns the status label
*/
public function __toString(): string
{
return $this->label();
}
/**
* Returns the status dialog
*/
public function dialog(): string|null
{
return $this->dialog;
}
/**
* Returns the status drawer
*/
public function drawer(): string|null
{
return $this->drawer;
}
/**
* Returns a status by its name
*/
public static function from(LicenseStatus|string|array|null $status): static
{
if ($status instanceof LicenseStatus) {
return $status;
}
if (is_array($status) === true) {
return new static(...$status);
}
$status = SystemLicenseStatus::from($status ?? 'unknown');
$status ??= SystemLicenseStatus::Unknown;
return new static(
value: $status->value,
icon: $status->icon(),
label: $status->label(),
theme: $status->theme()
);
}
/**
* Returns the status icon
*/
public function icon(): string
{
return $this->icon;
}
/**
* Returns the status label
*/
public function label(): string
{
return $this->label;
}
/**
* Returns the status link
*/
public function link(): string|null
{
return $this->link;
}
/**
* Returns the theme
*/
public function theme(): string|null
{
return $this->theme;
}
/**
* Returns the status information as an array
*/
public function toArray(): array
{
return [
'dialog' => $this->dialog(),
'drawer' => $this->drawer(),
'icon' => $this->icon(),
'label' => $this->label(),
'link' => $this->link(),
'theme' => $this->theme(),
'value' => $this->value(),
];
}
/**
* Returns the status value
*/
public function value(): string
{
return $this->value;
}
}

View file

@ -0,0 +1,354 @@
<?php
namespace Kirby\Plugin;
use Closure;
use Composer\InstalledVersions;
use Kirby\Cms\App;
use Kirby\Cms\Helpers;
use Kirby\Cms\System\UpdateStatus;
use Kirby\Data\Data;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Toolkit\A;
use Kirby\Toolkit\Str;
use Kirby\Toolkit\V;
use Throwable;
/**
* Represents a Plugin and handles parsing of
* the composer.json. It also creates the prefix
* and media url for the plugin.
*
* @package Kirby Plugin
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Plugin
{
protected Assets $assets;
protected License|Closure|array|string $license;
protected UpdateStatus|null $updateStatus = null;
/**
* @param string $name Plugin name within Kirby (`vendor/plugin`)
* @param array $extends Associative array of plugin extensions
*
* @throws \Kirby\Exception\InvalidArgumentException If the plugin name has an invalid format
*/
public function __construct(
protected string $name,
protected array $extends = [],
protected array $info = [],
Closure|string|array|null $license = null,
protected string|null $root = null,
protected string|null $version = null,
) {
static::validateName($name);
// TODO: Remove in v7
if ($root = $extends['root'] ?? null) {
Helpers::deprecated('Plugin "' . $name . '": Passing the `root` inside the `extends` array has been deprecated. Pass it directly as named argument `root`.', 'plugin-extends-root');
$this->root ??= $root;
unset($this->extends['root']);
}
$this->root ??= dirname(debug_backtrace()[0]['file']);
// TODO: Remove in v7
if ($info = $extends['info'] ?? null) {
Helpers::deprecated('Plugin "' . $name . '": Passing an `info` array inside the `extends` array has been deprecated. Pass the individual entries directly as named `info` argument.', 'plugin-extends-root');
if (empty($info) === false && is_array($info) === true) {
$this->info = [...$info, ...$this->info];
}
unset($this->extends['info']);
}
// read composer.json and use as info fallback
$info = Data::read($this->manifest(), fail: false);
$this->info = [...$info, ...$this->info];
$this->license = $license ?? $this->info['license'] ?? '-';
}
/**
* Allows access to any composer.json field by method call
*/
public function __call(string $key, array|null $arguments = null): mixed
{
return $this->info()[$key] ?? null;
}
/**
* Returns the plugin asset object for a specific asset
*/
public function asset(string $path): Asset|null
{
return $this->assets()->get($path);
}
/**
* Returns the plugin assets collection
*/
public function assets(): Assets
{
return $this->assets ??= Assets::factory($this);
}
/**
* Returns the array with author information
* from the composer.json file
*/
public function authors(): array
{
return $this->info()['authors'] ?? [];
}
/**
* Returns a comma-separated list with all author names
*/
public function authorsNames(): string
{
$names = [];
foreach ($this->authors() as $author) {
$names[] = $author['name'] ?? null;
}
return implode(', ', array_filter($names));
}
/**
* Returns the associative array of extensions the plugin bundles
*/
public function extends(): array
{
return $this->extends;
}
/**
* Returns the unique ID for the plugin
* (alias for the plugin name)
*/
public function id(): string
{
return $this->name();
}
/**
* Returns the info data (from composer.json)
*/
public function info(): array
{
return $this->info;
}
/**
* Current $kirby instance
*/
public function kirby(): App
{
return App::instance();
}
/**
* Returns the link to the plugin homepage
*/
public function link(): string|null
{
$info = $this->info();
$homepage = $info['homepage'] ?? null;
$docs = $info['support']['docs'] ?? null;
$source = $info['support']['source'] ?? null;
$link = $homepage ?? $docs ?? $source;
return V::url($link) ? $link : null;
}
/**
* Returns the license object
*/
public function license(): License
{
// resolve license info from Closure, array or string
return License::from(
plugin: $this,
license: $this->license
);
}
/**
* Returns the path to the plugin's composer.json
*/
public function manifest(): string
{
return $this->root() . '/composer.json';
}
/**
* Returns the root where plugin assets are copied to
*/
public function mediaRoot(): string
{
return $this->kirby()->root('media') . '/plugins/' . $this->name();
}
/**
* Returns the base URL for plugin assets
*/
public function mediaUrl(): string
{
return $this->kirby()->url('media') . '/plugins/' . $this->name();
}
/**
* Returns the plugin name (`vendor/plugin`)
*/
public function name(): string
{
return $this->name;
}
/**
* Returns a Kirby option value for this plugin
*/
public function option(string $key)
{
return $this->kirby()->option($this->prefix() . '.' . $key);
}
/**
* Returns the option prefix (`vendor.plugin`)
*/
public function prefix(): string
{
return str_replace('/', '.', $this->name());
}
/**
* Returns the root where the plugin files are stored
*/
public function root(): string
{
return $this->root;
}
/**
* Returns all available plugin metadata
*/
public function toArray(): array
{
return [
'authors' => $this->authors(),
'description' => $this->description(),
'name' => $this->name(),
'license' => $this->license()->toArray(),
'link' => $this->link(),
'root' => $this->root(),
'version' => $this->version()
];
}
/**
* Returns the update status object unless the
* update check has been disabled for the plugin
* @since 3.8.0
*
* @param array|null $data Custom override for the getkirby.com update data
*/
public function updateStatus(array|null $data = null): UpdateStatus|null
{
if ($this->updateStatus !== null) {
return $this->updateStatus;
}
$kirby = $this->kirby();
$option = $kirby->option('updates.plugins');
// specific configuration per plugin
if (is_array($option) === true) {
// filter all option values by glob match
$option = A::filter(
$option,
fn ($value, $key) => fnmatch($key, $this->name()) === true
);
// sort the matches by key length (with longest key first)
$keys = array_map('strlen', array_keys($option));
array_multisort($keys, SORT_DESC, $option);
if ($option !== []) {
// use the first and therefore longest key (= most specific match)
$option = reset($option);
} else {
// fallback to the default option value
$option = true;
}
}
$option ??= $kirby->option('updates') ?? true;
if ($option !== true) {
return null;
}
return $this->updateStatus = new UpdateStatus($this, false, $data);
}
/**
* Checks if the name follows the required pattern
* and throws an exception if not
*
* @throws \Kirby\Exception\InvalidArgumentException
*/
public static function validateName(string $name): void
{
if (preg_match('!^[a-z0-9-]+\/[a-z0-9-]+$!i', $name) !== 1) {
throw new InvalidArgumentException(
message: 'The plugin name must follow the format "a-z0-9-/a-z0-9-"'
);
}
}
/**
* Returns the normalized version number
* from the composer.json file
*/
public function version(): string|null
{
$name = $this->info()['name'] ?? null;
try {
// try to get version from "vendor/composer/installed.php",
// this is the most reliable source for the version
$version = InstalledVersions::getPrettyVersion($name);
} catch (Throwable) {
$version = null;
}
// fallback to the version provided in the plugin's index.php: as named
// argument, entry in the info array or from the composer.json file
$version ??= $this->version ?? $this->info()['version'] ?? null;
if (
is_string($version) !== true ||
$version === '' ||
Str::endsWith($version, '+no-version-set')
) {
return null;
}
// normalize the version number to be without leading `v`
$version = ltrim($version, 'vV');
// ensure that the version number now starts with a digit
if (preg_match('/^[0-9]/', $version) !== 1) {
return null;
}
return $version;
}
}