findResource('cache://') . '/api/thumbnails'; $thumbnailService = new ThumbnailService($cacheDir); $baseUrl = '/' . trim($config->get('plugins.api.route', '/api'), '/') . '/' . $config->get('plugins.api.version_prefix', 'v1'); $mediaSerializer = new MediaSerializer($thumbnailService, $baseUrl); $this->serializer = new PageSerializer($mediaSerializer); } /** * GET /pages - List pages with filtering, sorting, and pagination. */ public function index(ServerRequestInterface $request): ResponseInterface { $this->requirePermission($request, self::PERMISSION_READ); $previousLang = $this->applyLanguage($request); try { $directory = $this->getFlexDirectory('pages'); if ($directory) { return $this->indexViaFlex($request, $directory); } return $this->indexViaPages($request); } finally { $this->restoreLanguage($previousLang); } } /** * List pages using the Flex-Objects backend (indexed, cached). */ private function indexViaFlex(ServerRequestInterface $request, FlexDirectory $directory): ResponseInterface { $filters = $this->getFilters($request, self::ALLOWED_FILTERS); $sorting = $this->getSorting($request, self::ALLOWED_SORT_FIELDS); $pagination = $this->getPagination($request); $query = $request->getQueryParams(); $search = $query['search'] ?? null; $sortField = $sorting['sort'] ?? 'date'; $sortOrder = $sorting['sort'] ? $sorting['order'] : 'desc'; // 'default' sort with children_of: use native page ordering if ($sortField === 'default' && isset($filters['children_of'])) { return $this->indexViaDefaultSort($request, $filters['children_of'], $filters, $pagination); } if ($sortField === 'default') { $sortField = 'order'; $sortOrder = 'asc'; } // Start with full collection $collection = $directory->getCollection(); // Apply search if ($search && $search !== '') { $collection = $collection->search($search); } // Apply filters using flex methods where available if (isset($filters['published'])) { $bool = filter_var($filters['published'], FILTER_VALIDATE_BOOLEAN); if (method_exists($collection, 'withPublished')) { $collection = $collection->withPublished($bool); } } if (isset($filters['visible'])) { $bool = filter_var($filters['visible'], FILTER_VALIDATE_BOOLEAN); if (method_exists($collection, 'withVisible')) { $collection = $collection->withVisible($bool); } } if (isset($filters['routable'])) { $bool = filter_var($filters['routable'], FILTER_VALIDATE_BOOLEAN); if (method_exists($collection, 'withRoutable')) { $collection = $collection->withRoutable($bool); } } // Template, parent, children_of, root — filter manually on the collection if (isset($filters['template']) || isset($filters['parent']) || isset($filters['children_of']) || isset($filters['root'])) { $filtered = []; foreach ($collection as $page) { if ($page instanceof PageInterface && $this->matchesFilters($page, $filters)) { $filtered[$page->getKey()] = $page; } } // Re-select from the collection to maintain the flex type $collection = $collection->select(array_keys($filtered)); } // Map sort fields to flex-compatible field names $flexSortField = match ($sortField) { 'date' => 'date', 'modified' => 'timestamp', 'title' => 'title', 'slug' => 'slug', 'order' => 'order', default => 'date', }; $collection = $collection->sort([$flexSortField => $sortOrder]); // Skip the virtual pages-root container (no file on disk). The home // page IS a real file-backed page even though its route is '/'. $items = []; foreach ($collection as $page) { if ($page instanceof PageInterface && $page->route() && $page->exists()) { $items[] = $page; } } $total = count($items); $locatedAt = $this->applyLocate($items, $pagination, $query['locate'] ?? null); $slice = array_slice($items, $pagination['offset'], $pagination['limit']); $includeTranslations = filter_var( $request->getQueryParams()['translations'] ?? false, FILTER_VALIDATE_BOOLEAN ); $listOptions = [ 'include_content' => false, 'render_content' => false, 'include_children' => false, 'include_media' => false, 'include_translations' => $includeTranslations, ]; $data = $this->serializer->serializeCollection($slice, $listOptions); return ApiResponse::paginated( data: $data, total: $total, page: $pagination['page'], perPage: $pagination['per_page'], baseUrl: $this->getApiBaseUrl() . '/pages', locatedAtIndex: $locatedAt, ); } /** * List pages using the regular Grav Pages service (fallback). */ private function indexViaPages(ServerRequestInterface $request): ResponseInterface { $this->enablePages(); $filters = $this->getFilters($request, self::ALLOWED_FILTERS); $sorting = $this->getSorting($request, self::ALLOWED_SORT_FIELDS); $pagination = $this->getPagination($request); $sortField = $sorting['sort'] ?? 'date'; $sortOrder = $sorting['sort'] ? $sorting['order'] : 'desc'; if ($sortField === 'default' && isset($filters['children_of'])) { return $this->indexViaDefaultSort($request, $filters['children_of'], $filters, $pagination); } if ($sortField === 'default') { $sortField = 'order'; $sortOrder = 'asc'; } $pages = $this->grav['pages']; $allPages = $this->collectAndFilterPages($pages->instances(), $filters); $allPages = $this->sortPages($allPages, $sortField, $sortOrder); $total = count($allPages); $locatedAt = $this->applyLocate($allPages, $pagination, $request->getQueryParams()['locate'] ?? null); $slice = array_slice($allPages, $pagination['offset'], $pagination['limit']); $includeTranslations = filter_var( $request->getQueryParams()['translations'] ?? false, FILTER_VALIDATE_BOOLEAN ); $listOptions = [ 'include_content' => false, 'render_content' => false, 'include_children' => false, 'include_media' => false, 'include_translations' => $includeTranslations, ]; $data = $this->serializer->serializeCollection($slice, $listOptions); return ApiResponse::paginated( data: $data, total: $total, page: $pagination['page'], perPage: $pagination['per_page'], baseUrl: $this->getApiBaseUrl() . '/pages', locatedAtIndex: $locatedAt, ); } /** * GET /pages/{route} - Get a single page by route. */ public function show(ServerRequestInterface $request): ResponseInterface { $this->requirePermission($request, self::PERMISSION_READ); $previousLang = $this->applyLanguage($request); try { $this->enablePages(); $route = $this->getRouteParam($request, 'route'); $page = $this->findPageOrFail('/' . $route); // If the page already has process.twig:true, the same gate that // governs writes also governs reading the full record. Returning // the editor view to a user who can't save it is misleading; let // Admin Next show the toast on the show() failure instead. $this->guardTwigContent($page, [], $this->getUser($request)); $query = $request->getQueryParams(); $summary = filter_var($query['summary'] ?? false, FILTER_VALIDATE_BOOLEAN); $options = [ 'include_content' => !$summary, 'render_content' => filter_var($query['render'] ?? false, FILTER_VALIDATE_BOOLEAN), 'include_summary' => $summary, 'summary_size' => isset($query['summary_size']) ? (int) $query['summary_size'] : null, 'include_children' => filter_var($query['children'] ?? false, FILTER_VALIDATE_BOOLEAN), 'children_depth' => max(1, (int) ($query['children_depth'] ?? 1)), 'include_media' => true, 'include_translations' => filter_var($query['translations'] ?? false, FILTER_VALIDATE_BOOLEAN), ]; $data = $this->serializer->serialize($page, $options); return $this->respondWithEtag($data); } finally { $this->restoreLanguage($previousLang); } } /** * POST /pages - Create a new page. */ public function create(ServerRequestInterface $request): ResponseInterface { $this->requirePermission($request, self::PERMISSION_WRITE); $body = $this->getRequestBody($request); $this->requireFields($body, ['route', 'title']); // Language can come from body or query param $lang = $body['lang'] ?? null; $previousLang = $this->applyLanguage($request, $lang); try { $this->enablePages(); $route = '/' . trim($body['route'], '/'); $template = $body['template'] ?? 'default'; $title = $body['title']; $content = $body['content'] ?? ''; $header = $body['header'] ?? []; $order = $body['order'] ?? null; // `kind` mirrors classic admin's three-way split: // - 'page' (default): folder +