feat(demo): add story 1 — Sorano: Rock and Time

This commit is contained in:
2026-06-20 21:19:57 +02:00
parent 42ed59a6b3
commit 8f87155c1d
5508 changed files with 1595740 additions and 124 deletions
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Serializers;
use Grav\Common\Flex\Types\UserGroups\UserGroupObject;
class GroupSerializer implements SerializerInterface
{
public function serialize(object $resource, array $options = []): array
{
/** @var UserGroupObject $resource */
return [
'groupname' => (string) $resource->getProperty('groupname', ''),
'readableName' => (string) ($resource->getProperty('readableName') ?? ''),
'description' => (string) ($resource->getProperty('description') ?? ''),
'icon' => (string) ($resource->getProperty('icon') ?? ''),
'enabled' => (bool) $resource->getProperty('enabled', true),
'access' => $resource->getProperty('access') ?? [],
];
}
/**
* Serialize a plain array entry from groups.yaml (used by the fallback
* non-Flex listing path, where there is no UserGroupObject yet).
*
* @param array<string,mixed> $entry
*/
public function serializeArray(string $groupname, array $entry): array
{
return [
'groupname' => $groupname,
'readableName' => (string) ($entry['readableName'] ?? ''),
'description' => (string) ($entry['description'] ?? ''),
'icon' => (string) ($entry['icon'] ?? ''),
'enabled' => (bool) ($entry['enabled'] ?? true),
'access' => $entry['access'] ?? [],
];
}
}
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Serializers;
use Grav\Plugin\Api\Services\ThumbnailService;
class MediaSerializer implements SerializerInterface
{
public function __construct(
private ?ThumbnailService $thumbnailService = null,
private string $thumbnailBaseUrl = '',
) {}
/**
* Serialize a single Grav Medium object to an API response array.
*/
public function serialize(object $medium, array $options = []): array
{
$mime = $medium->get('mime') ?? 'application/octet-stream';
$data = [
'filename' => $medium->filename,
'url' => $medium->url(),
'type' => $mime,
'size' => (int) ($medium->get('size') ?? 0),
];
if (str_starts_with($mime, 'image/')) {
$width = $medium->get('width');
$height = $medium->get('height');
if ($width !== null && $height !== null) {
$data['dimensions'] = [
'width' => (int) $width,
'height' => (int) $height,
];
}
// Generate thumbnail URL for images
if ($this->thumbnailService) {
$sourcePath = $this->resolveSourcePath($medium);
if ($sourcePath) {
$thumbFilename = $this->thumbnailService->getThumbnailFilename($sourcePath);
if ($thumbFilename) {
// Trigger thumbnail generation
$this->thumbnailService->getThumbnail($sourcePath);
$data['thumbnail_url'] = $this->thumbnailBaseUrl . '/thumbnails/' . $thumbFilename;
}
}
}
}
$data['modified'] = $this->resolveModifiedTime($medium);
return $data;
}
/**
* Serialize an iterable collection of Medium objects.
*/
public function serializeCollection(iterable $media, array $options = []): array
{
$items = [];
foreach ($media as $medium) {
$items[] = $this->serialize($medium, $options);
}
return $items;
}
/**
* Resolve the physical file path for a medium.
*/
private function resolveSourcePath(object $medium): ?string
{
if (method_exists($medium, 'path')) {
$path = $medium->path();
if ($path && file_exists($path)) {
return $path;
}
}
return null;
}
/**
* Resolve the last-modified timestamp for a medium, returning an ISO 8601 string.
*/
private function resolveModifiedTime(object $medium): string
{
$timestamp = null;
if (method_exists($medium, 'modified')) {
$timestamp = $medium->modified();
}
if (!$timestamp && method_exists($medium, 'path')) {
$path = $medium->path();
if ($path && file_exists($path)) {
$timestamp = filemtime($path);
}
}
$timestamp = $timestamp ?: time();
return date(\DateTimeInterface::ATOM, (int) $timestamp);
}
}
@@ -0,0 +1,221 @@
<?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;
}
}
@@ -0,0 +1,312 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Serializers;
use DateTimeImmutable;
use DateTimeZone;
use Grav\Common\Page\Interfaces\PageInterface;
class PageSerializer implements SerializerInterface
{
public function __construct(
private ?MediaSerializer $mediaSerializer = null,
) {}
public function serialize(object $resource, array $options = []): array
{
/** @var PageInterface $resource */
$includeContent = $options['include_content'] ?? true;
$renderContent = $options['render_content'] ?? false;
$includeChildren = $options['include_children'] ?? false;
$childrenDepth = $options['children_depth'] ?? 1;
$includeMedia = $options['include_media'] ?? true;
$includeTranslations = $options['include_translations'] ?? false;
$headerArr = $this->serializeHeader($resource->header());
// Flex-indexed PageObject instances expose EMPTY headers during
// listing (the index only materializes summary fields). That makes
// $resource->published() / visible() fall back to Grav's default
// "true" even when the frontmatter explicitly says false. Swap in the
// fully-loaded legacy Page so every downstream field reads correctly.
// Flex-indexed PageObject instances expose EMPTY headers during
// listing (the index only materializes summary fields). Read the
// frontmatter directly from the .md file so published/visible and
// everything else in the header are accurate regardless of which
// controller path we came through.
if (empty($headerArr) && $resource instanceof \Grav\Framework\Flex\Pages\FlexPageObject) {
$path = method_exists($resource, 'path') ? $resource->path() : null;
$template = $resource->template();
if ($path && $template) {
$candidates = [];
// Prefer the page's own language, then the active language,
// then the untyped default, then any matching {template}*.md.
$pageLang = $resource->language();
if ($pageLang) {
$candidates[] = $path . '/' . $template . '.' . $pageLang . '.md';
}
$grav = \Grav\Common\Grav::instance();
$lang = $grav['language'] ?? null;
if ($lang && method_exists($lang, 'getLanguage')) {
$active = $lang->getLanguage();
if ($active) {
$candidates[] = $path . '/' . $template . '.' . $active . '.md';
}
}
$candidates[] = $path . '/' . $template . '.md';
foreach ($candidates as $file) {
if (is_file($file)) {
$parsed = $this->parseFrontmatter($file);
if (!empty($parsed)) {
$headerArr = $parsed;
break;
}
}
}
// Fallback: glob for any {template}*.md file in the directory
if (empty($headerArr)) {
foreach (glob($path . '/' . $template . '*.md') ?: [] as $file) {
$parsed = $this->parseFrontmatter($file);
if (!empty($parsed)) {
$headerArr = $parsed;
break;
}
}
}
}
}
// For flex-indexed PageObject listings the in-memory header is empty
// (we re-parsed the .md file into $headerArr above). $resource->title()
// and ->menu() in that mode fall back to a slug-derived label
// ("Contact-us") even when the frontmatter has a real title.
// Prefer the parsed-header value so the listing reads the same as
// the detail endpoint.
$headerTitle = $headerArr['title'] ?? null;
$headerMenu = $headerArr['menu'] ?? null;
$data = [
'route' => $resource->route(),
// Structural route — for the home page, route() returns the
// public alias '/' but rawRoute() returns the actual page like
// '/home'. Clients editing/finding pages should prefer this.
'raw_route' => $resource->rawRoute(),
'slug' => $resource->slug(),
// The on-disk folder basename, including any numeric ordering
// prefix (e.g. `01.consulting`). `slug` is the prefix-stripped
// name; admin UIs need the real folder to show/diagnose ordering.
'folder' => $resource->folder(),
'title' => is_string($headerTitle) && $headerTitle !== '' ? $headerTitle : $resource->title(),
'menu' => is_string($headerMenu) && $headerMenu !== '' ? $headerMenu : (is_string($headerTitle) && $headerTitle !== '' ? $headerTitle : $resource->menu()),
'template' => $resource->template(),
'language' => $resource->language(),
'header' => $headerArr,
'taxonomy' => $resource->taxonomy(),
// Prefer the explicit frontmatter value for published/visible over
// the object method. During flex-indexed collection listings
// (GET /pages) the indexed PageObject::published() can return a
// stale/default "true" when the header isn't fully materialized,
// while GET /pages/{route} goes through enablePages() and reads a
// legacy Page where the method is correct. Reading the serialized
// header array (same one we return to the client) gives the same
// answer in both paths.
'published' => array_key_exists('published', $headerArr) ? (bool)$headerArr['published'] : $resource->published(),
'visible' => array_key_exists('visible', $headerArr) ? (bool)$headerArr['visible'] : $resource->visible(),
'routable' => $resource->routable(),
'date' => $this->formatTimestamp($resource->date()),
'modified' => $this->formatTimestamp($resource->modified()),
'order' => $resource->order(),
'has_children' => count($resource->children()) > 0,
];
if ($includeTranslations) {
$data['translated_languages'] = $resource->translatedLanguages();
$data['untranslated_languages'] = $resource->untranslatedLanguages();
// Disambiguate Grav's translated_languages response: when the page
// has an untyped base file (e.g. default.md), Grav reports every
// site language as "translated" because default.md acts as a
// fallback for any active lang. These two fields let admin UIs
// tell whether each language is backed by an EXPLICIT file
// (default.<lang>.md) or by the implicit default.md fallback.
$pagePath = $resource->path();
$template = $resource->template();
$data['has_default_file'] = $pagePath && $template
? is_file($pagePath . '/' . $template . '.md')
: false;
// List of language codes that have a concrete `{template}.{lang}.md`
// file on disk. Everything else in translated_languages is falling
// back to default.md. Empty array when multilang is off.
$explicit = [];
if ($pagePath && $template) {
$lang = \Grav\Common\Grav::instance()['language'] ?? null;
$langCodes = $lang && method_exists($lang, 'getLanguages')
? (array) $lang->getLanguages()
: [];
foreach ($langCodes as $code) {
if (is_file($pagePath . '/' . $template . '.' . $code . '.md')) {
$explicit[] = $code;
}
}
}
$data['explicit_language_files'] = $explicit;
}
if ($includeContent) {
$data['content'] = $resource->rawMarkdown();
}
if ($renderContent) {
$data['content_html'] = $resource->content();
}
$includeSummary = $options['include_summary'] ?? false;
if ($includeSummary) {
$summarySize = $options['summary_size'] ?? null;
// summary() runs the page through the full Twig / shortcode pipeline,
// so any page with a plugin shortcode whose dependencies aren't
// available in the API request context (e.g. a `[poll]` that wants
// the frontend theme's Twig env) can throw — we don't want that to
// take down the whole response for something the client is treating
// as a preview. Fall back to a plain-text rendering of the raw
// markdown, trimmed to the requested size.
try {
$data['summary'] = $summarySize
? $resource->summary($summarySize)
: $resource->summary();
} catch (\Throwable $e) {
$raw = (string) $resource->rawMarkdown();
// Strip frontmatter artifacts, shortcodes, markdown syntax.
$plain = preg_replace('/\[[^\]]+\s*\/?\]/', '', $raw) ?? $raw;
$plain = preg_replace('/[#*_`>]/', '', $plain) ?? $plain;
$plain = trim(preg_replace('/\s+/', ' ', $plain) ?? $plain);
$max = $summarySize ?: 300;
$data['summary'] = mb_strlen($plain) > $max
? rtrim(mb_substr($plain, 0, $max)) . '…'
: $plain;
}
}
if ($includeMedia) {
$data['media'] = $this->serializeMedia($resource);
}
if ($includeChildren && $childrenDepth > 0) {
$data['children'] = $this->serializeChildren(
$resource,
$options,
$childrenDepth,
);
}
return $data;
}
/**
* Parse the YAML frontmatter from a Grav .md file. Returns the header
* array, or empty array if there's no frontmatter / on parse failure.
*/
private function parseFrontmatter(string $file): array
{
$contents = @file_get_contents($file);
if ($contents === false) {
return [];
}
// Grav frontmatter: content between leading `---\n` and the next `---\n`.
if (!preg_match('/^---\r?\n(.*?)\r?\n---\r?\n/s', $contents, $m)) {
return [];
}
try {
$parsed = \Symfony\Component\Yaml\Yaml::parse($m[1]);
return is_array($parsed) ? $parsed : [];
} catch (\Throwable) {
return [];
}
}
/**
* Serialize a collection of pages.
*/
public function serializeCollection(iterable $pages, array $options = []): array
{
$result = [];
foreach ($pages as $page) {
$result[] = $this->serialize($page, $options);
}
return $result;
}
/**
* Convert page header object to an associative array.
*/
private function serializeHeader(object|null $header): array
{
if ($header === null) {
return [];
}
return json_decode(json_encode($header), true) ?: [];
}
/**
* Serialize the media collection attached to a page.
*/
private function serializeMedia(PageInterface $page): array
{
$media = $page->media();
if ($this->mediaSerializer) {
return $this->mediaSerializer->serializeCollection($media->all());
}
$result = [];
foreach ($media->all() as $filename => $medium) {
$result[] = [
'filename' => $medium->filename,
'type' => $medium->get('mime'),
'size' => $medium->get('size'),
];
}
return $result;
}
/**
* Recursively serialize children pages up to the specified depth.
*/
private function serializeChildren(PageInterface $page, array $options, int $depth): array
{
$childOptions = array_merge($options, [
'include_children' => $depth > 1,
'children_depth' => $depth - 1,
]);
$result = [];
foreach ($page->children() as $child) {
$result[] = $this->serialize($child, $childOptions);
}
return $result;
}
/**
* Format a Unix timestamp as ISO 8601.
*/
private function formatTimestamp(int|null $timestamp): ?string
{
if ($timestamp === null || $timestamp === 0) {
return null;
}
return (new DateTimeImmutable('@' . $timestamp))
->setTimezone(new DateTimeZone('UTC'))
->format(DateTimeImmutable::ATOM);
}
}
@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Serializers;
interface SerializerInterface
{
public function serialize(object $resource, array $options = []): array;
}
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Serializers;
use DateTimeImmutable;
use DateTimeZone;
use Grav\Common\User\Interfaces\UserInterface;
class UserSerializer implements SerializerInterface
{
public function serialize(object $resource, array $options = []): array
{
/** @var UserInterface $resource */
return [
'username' => $resource->username,
'email' => $resource->get('email'),
'fullname' => $resource->get('fullname'),
'title' => $resource->get('title'),
'state' => $resource->get('state', 'enabled'),
'language' => $resource->get('language', ''),
'content_editor' => $resource->get('content_editor', ''),
'access' => $resource->get('access', []),
'groups' => array_values(array_filter(
(array) $resource->get('groups', []),
'is_string',
)),
'avatar_url' => self::resolveAvatarUrl($resource),
'twofa_enabled' => (bool) $resource->get('twofa_enabled', false),
'twofa_secret' => $resource->get('twofa_secret') ? true : false,
'created' => $this->formatTimestamp($resource->get('created')),
'modified' => $this->formatTimestamp($resource->get('modified')),
];
}
/**
* Resolve the avatar URL for a user.
* Returns the URL to an uploaded avatar, or null if none exists.
*/
public static function resolveAvatarUrl(UserInterface $resource): ?string
{
$avatar = $resource->get('avatar');
// Avatar is stored as { filename: { name, type, size, path } } or similar
if (is_array($avatar) && !empty($avatar)) {
$first = reset($avatar);
if (is_array($first) && isset($first['path'])) {
// path is relative to Grav root (e.g. user/accounts/avatars/file.jpg)
$filePath = GRAV_ROOT . '/' . $first['path'];
if (file_exists($filePath)) {
// Generate a thumbnail URL via the thumbnail service
$locator = \Grav\Common\Grav::instance()['locator'];
$cacheDir = $locator->findResource('cache://', true, true) . '/api/thumbnails';
$thumbService = new \Grav\Plugin\Api\Services\ThumbnailService($cacheDir, 200);
$filename = $thumbService->getThumbnailFilename($filePath);
if ($filename) {
$thumbService->getThumbnail($filePath);
$config = \Grav\Common\Grav::instance()['config'];
$route = $config->get('plugins.api.route', '/api');
$prefix = $config->get('plugins.api.version_prefix', 'v1');
return $route . '/' . $prefix . '/thumbnails/' . $filename;
}
}
}
}
return null;
}
private function formatTimestamp(mixed $timestamp): ?string
{
if ($timestamp === null || $timestamp === 0) {
return null;
}
return (new DateTimeImmutable('@' . (int) $timestamp))
->setTimezone(new DateTimeZone('UTC'))
->format(DateTimeImmutable::ATOM);
}
}