feat(demo): add story 1 — Sorano: Rock and Time
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user