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,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);
}
}