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