222 lines
7.6 KiB
PHP
222 lines
7.6 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Grav\Plugin\Api\Serializers;
|
|
|
|
use Grav\Common\GPM\Licenses;
|
|
use Parsedown;
|
|
|
|
class PackageSerializer implements SerializerInterface
|
|
{
|
|
private static ?Parsedown $parsedown = null;
|
|
|
|
public function serialize(object $resource, array $options = []): array
|
|
{
|
|
$description = $resource->description ?? null;
|
|
|
|
$data = [
|
|
'slug' => $resource->slug ?? null,
|
|
'name' => $resource->name ?? null,
|
|
'version' => $resource->version ?? null,
|
|
'type' => $options['type'] ?? null,
|
|
'description' => $description,
|
|
'description_html' => $this->renderMarkdown($description),
|
|
'author' => $this->serializeAuthor($resource),
|
|
'homepage' => $resource->homepage ?? $resource->url ?? null,
|
|
];
|
|
|
|
// Include enabled status + symlink detection for installed packages
|
|
if ($options['installed'] ?? false) {
|
|
$data['enabled'] = $this->isEnabled($resource, $options);
|
|
$data['is_symlink'] = $this->isSymlinked($resource, $options);
|
|
}
|
|
|
|
// Include update info if available
|
|
if (isset($resource->available)) {
|
|
$data['available_version'] = $resource->available;
|
|
$data['updatable'] = !empty($resource->available);
|
|
}
|
|
|
|
// Include premium status and purchase info
|
|
if (!empty($resource->premium)) {
|
|
$slug = $resource->slug ?? $options['slug_key'] ?? '';
|
|
$premium = $resource->premium;
|
|
$permalink = is_object($premium) ? ($premium->permalink ?? null) : ($premium['permalink'] ?? null);
|
|
|
|
$data['premium'] = true;
|
|
$data['licensed'] = !empty(Licenses::get($slug));
|
|
|
|
if ($permalink) {
|
|
$data['purchase_url'] = 'https://licensing.getgrav.org/buy/' . $permalink;
|
|
}
|
|
}
|
|
|
|
// Include dependencies
|
|
if (!empty($resource->dependencies)) {
|
|
$data['dependencies'] = $resource->dependencies;
|
|
}
|
|
|
|
// Include compatibility metadata. Grav core resolves
|
|
// `compatibility.grav` / `compatibility.api` (and infers grav from the
|
|
// dependencies array as a fallback). Any keys core doesn't currently
|
|
// resolve (e.g. a future `compatibility.php`) come straight from the
|
|
// blueprint via the `compatibility_raw` fallback below.
|
|
$compatibility = $this->normalizeCompatibility($resource->compatibility ?? null);
|
|
$rawCompat = is_object($resource) && method_exists($resource, 'toArray')
|
|
? ($resource->toArray()['compatibility'] ?? null)
|
|
: null;
|
|
if (is_array($rawCompat)) {
|
|
foreach ($rawCompat as $key => $value) {
|
|
if (!isset($compatibility[$key])) {
|
|
$compatibility[$key] = is_array($value) ? array_map('strval', $value) : (string) $value;
|
|
}
|
|
}
|
|
}
|
|
if (!empty($compatibility)) {
|
|
$data['compatibility'] = $compatibility;
|
|
}
|
|
|
|
// Include keywords/tags
|
|
if (!empty($resource->keywords)) {
|
|
$data['keywords'] = $resource->keywords;
|
|
}
|
|
|
|
// Include icon
|
|
if (!empty($resource->icon)) {
|
|
$data['icon'] = $resource->icon;
|
|
}
|
|
|
|
// Include screenshot URL for themes (from GPM repository data)
|
|
if (!empty($resource->screenshot)) {
|
|
$screenshot = $resource->screenshot;
|
|
// GPM returns just a filename — resolve to full URL
|
|
if (!str_starts_with($screenshot, 'http')) {
|
|
$screenshot = 'https://getgrav.org/images/' . $screenshot;
|
|
}
|
|
$data['screenshot'] = $screenshot;
|
|
}
|
|
|
|
return $data;
|
|
}
|
|
|
|
/**
|
|
* Serialize a collection of packages.
|
|
*/
|
|
public function serializeCollection(iterable $packages, array $options = []): array
|
|
{
|
|
$result = [];
|
|
|
|
foreach ($packages as $slug => $package) {
|
|
$opts = array_merge($options, ['slug_key' => $slug]);
|
|
$serialized = $this->serialize($package, $opts);
|
|
// Ensure slug is set (some iterators use slug as key)
|
|
if ($serialized['slug'] === null && is_string($slug)) {
|
|
$serialized['slug'] = $slug;
|
|
}
|
|
$result[] = $serialized;
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
private function serializeAuthor(object $resource): ?array
|
|
{
|
|
$author = $resource->author ?? null;
|
|
|
|
if ($author === null) {
|
|
return null;
|
|
}
|
|
|
|
if (is_object($author)) {
|
|
return [
|
|
'name' => $author->name ?? null,
|
|
'email' => $author->email ?? null,
|
|
'url' => $author->url ?? null,
|
|
];
|
|
}
|
|
|
|
if (is_array($author)) {
|
|
return [
|
|
'name' => $author['name'] ?? null,
|
|
'email' => $author['email'] ?? null,
|
|
'url' => $author['url'] ?? null,
|
|
];
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function isEnabled(object $resource, array $options): bool
|
|
{
|
|
$type = $options['type'] ?? 'plugin';
|
|
$slug = $resource->slug ?? $options['slug_key'] ?? '';
|
|
|
|
if ($type === 'plugin') {
|
|
return (bool) (\Grav\Common\Grav::instance()['config']->get("plugins.{$slug}.enabled", false));
|
|
}
|
|
|
|
// For themes, check if it's the active theme
|
|
$activeTheme = \Grav\Common\Grav::instance()['config']->get('system.pages.theme');
|
|
return $slug === $activeTheme;
|
|
}
|
|
|
|
/**
|
|
* Render a plugin/theme description as safe HTML. Descriptions are
|
|
* YAML-authored and routinely contain inline markdown (links, bold,
|
|
* emphasis) that renders as literal syntax in UIs without processing.
|
|
* Returns null for empty input so clients can trivially fall back.
|
|
*/
|
|
private function renderMarkdown(?string $markdown): ?string
|
|
{
|
|
if ($markdown === null || $markdown === '') {
|
|
return null;
|
|
}
|
|
if (self::$parsedown === null) {
|
|
self::$parsedown = new Parsedown();
|
|
// Untrusted YAML input — sanitize any inline HTML and disable unsafe protocols.
|
|
self::$parsedown->setSafeMode(true);
|
|
self::$parsedown->setBreaksEnabled(false);
|
|
}
|
|
return self::$parsedown->text($markdown);
|
|
}
|
|
|
|
/**
|
|
* Normalize Grav's resolved compatibility array into a stable client shape.
|
|
* Strips empty keys so consumers don't render `Grav: ` with nothing after.
|
|
*
|
|
* @param mixed $compatibility
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function normalizeCompatibility($compatibility): array
|
|
{
|
|
if (!is_array($compatibility)) {
|
|
return [];
|
|
}
|
|
$out = [];
|
|
foreach ($compatibility as $key => $value) {
|
|
if (is_array($value)) {
|
|
$value = array_values(array_filter(array_map('strval', $value), 'strlen'));
|
|
if (!empty($value)) {
|
|
$out[(string) $key] = $value;
|
|
}
|
|
} elseif ($value !== null && $value !== '') {
|
|
$out[(string) $key] = (string) $value;
|
|
}
|
|
}
|
|
return $out;
|
|
}
|
|
|
|
private function isSymlinked(object $resource, array $options): bool
|
|
{
|
|
$type = $options['type'] ?? 'plugin';
|
|
$slug = $resource->slug ?? $options['slug_key'] ?? '';
|
|
if (!$slug) {
|
|
return false;
|
|
}
|
|
$scheme = $type === 'theme' ? 'themes' : 'plugins';
|
|
$path = \Grav\Common\Grav::instance()['locator']->findResource("{$scheme}://{$slug}", true);
|
|
return $path ? is_link($path) : false;
|
|
}
|
|
}
|