1420 lines
56 KiB
PHP
1420 lines
56 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Grav\Plugin\Api\Controllers;
|
|
|
|
use Grav\Common\Data\Blueprint;
|
|
use Grav\Common\Page\Pages;
|
|
use Grav\Common\User\Interfaces\UserInterface;
|
|
use Grav\Plugin\Api\Exceptions\NotFoundException;
|
|
use Grav\Plugin\Api\Exceptions\ValidationException;
|
|
use Grav\Plugin\Api\Response\ApiResponse;
|
|
use Grav\Plugin\Api\Services\ConfigScopes;
|
|
use Grav\Plugin\Api\Services\DisabledPluginLangIndex;
|
|
use Grav\Plugin\Api\Services\PreferencesResolver;
|
|
use Psr\Http\Message\ResponseInterface;
|
|
use Psr\Http\Message\ServerRequestInterface;
|
|
use RocketTheme\Toolbox\Event\Event;
|
|
use Throwable;
|
|
|
|
class BlueprintController extends AbstractApiController
|
|
{
|
|
private ?DisabledPluginLangIndex $disabledLangIndex = null;
|
|
|
|
/**
|
|
* Language fallback chain used when translating blueprint labels for the
|
|
* current request — typically [$userAdminLanguage, 'en']. Resolved lazily
|
|
* via {@see resolveBlueprintLanguages()} and cached on the instance, so
|
|
* the per-request preference lookup runs at most once per blueprint
|
|
* payload regardless of how many labels need translating.
|
|
*
|
|
* @var array<int, string>|null
|
|
*/
|
|
private ?array $blueprintLanguages = null;
|
|
|
|
/**
|
|
* Map of primary subtag => shipped region-suffixed locale codes, e.g.
|
|
* `['en' => ['en-US'], 'de' => ['de-DE']]`. Cached per request.
|
|
*
|
|
* @var array<string, array<int, string>>|null
|
|
*/
|
|
private ?array $regionVariantIndex = null;
|
|
|
|
private function disabledLangIndex(): DisabledPluginLangIndex
|
|
{
|
|
return $this->disabledLangIndex ??= new DisabledPluginLangIndex($this->grav);
|
|
}
|
|
|
|
/**
|
|
* Resolve the language chain for blueprint label translation. Prefers the
|
|
* authenticated user's `adminLanguage` preference (which the SPA picks),
|
|
* with 'en' as a fallback so any keys not yet translated still come
|
|
* through in English instead of being humanized.
|
|
*
|
|
* Why this is needed: Grav's `Language::translate()` falls back to the
|
|
* site's active content language when called with no `$languages` hint —
|
|
* that's typically 'en' even for an admin user who has selected Hebrew
|
|
* for their UI. The dict endpoint (`/translations/{lang}`) already
|
|
* accepts an explicit language, so admin-next's client-side i18n works,
|
|
* but blueprint labels are pre-resolved server-side here.
|
|
*
|
|
* @return array<int, string>
|
|
*/
|
|
private function resolveBlueprintLanguages(ServerRequestInterface $request): array
|
|
{
|
|
if ($this->blueprintLanguages !== null) {
|
|
return $this->blueprintLanguages;
|
|
}
|
|
|
|
$lang = 'en';
|
|
try {
|
|
$user = $this->getUser($request);
|
|
$resolver = new PreferencesResolver($this->grav, $this->config);
|
|
$effective = $resolver->resolve($user, false)['effective'] ?? [];
|
|
$candidate = $effective['adminLanguage'] ?? null;
|
|
if (is_string($candidate) && $candidate !== '') {
|
|
$lang = $candidate;
|
|
}
|
|
} catch (Throwable) {
|
|
// Unauthenticated or resolver failure — fall back to English.
|
|
}
|
|
|
|
return $this->blueprintLanguages = $this->expandLanguageChain($lang);
|
|
}
|
|
|
|
/**
|
|
* Build the translation fallback chain for a requested admin language.
|
|
*
|
|
* The requested language comes first and English is the universal tail
|
|
* fallback. Each entry is then expanded to include any region-suffixed
|
|
* variant that ships on disk: admin2 stores its dictionary under e.g.
|
|
* `en-US.yaml` (not `en.yaml`), and Grav indexes plugin language files by
|
|
* the filename's locale code. Without this expansion a user whose
|
|
* preference is the bare 2-char `en` never reaches admin2's `en-US` strings
|
|
* and every blueprint label/help falls through to the humaniser
|
|
* (getgrav/grav-admin-next#1). Expanding `en` → `['en', 'en-US']` (and
|
|
* likewise `de` → `['de', 'de-DE']`) lets the shipped region file serve the
|
|
* bare code, so no duplicate `en.yaml` is needed.
|
|
*
|
|
* @return array<int, string>
|
|
*/
|
|
private function expandLanguageChain(string $lang): array
|
|
{
|
|
$chain = [];
|
|
foreach ([$lang, 'en'] as $code) {
|
|
foreach (array_merge([$code], $this->regionVariantsFor($code)) as $candidate) {
|
|
if (!in_array($candidate, $chain, true)) {
|
|
$chain[] = $candidate;
|
|
}
|
|
}
|
|
}
|
|
|
|
return $chain;
|
|
}
|
|
|
|
/**
|
|
* Region-suffixed locale codes shipped for a bare primary subtag, e.g.
|
|
* `en` => `['en-US']`. Already-regioned codes (containing `-`) need no
|
|
* expansion and return an empty list.
|
|
*
|
|
* @return array<int, string>
|
|
*/
|
|
private function regionVariantsFor(string $code): array
|
|
{
|
|
if (str_contains($code, '-')) {
|
|
return [];
|
|
}
|
|
|
|
return $this->buildRegionVariantIndex()[$code] ?? [];
|
|
}
|
|
|
|
/**
|
|
* Discover shipped region variants from admin2's languages directory (where
|
|
* the SPA's translation dictionary lives). Cached for the request.
|
|
*
|
|
* @return array<string, array<int, string>>
|
|
*/
|
|
private function buildRegionVariantIndex(): array
|
|
{
|
|
if ($this->regionVariantIndex !== null) {
|
|
return $this->regionVariantIndex;
|
|
}
|
|
|
|
$index = [];
|
|
$dir = $this->grav['locator']->findResource('plugin://admin2/languages')
|
|
?: (defined('GRAV_ROOT') ? GRAV_ROOT . '/user/plugins/admin2/languages' : null);
|
|
|
|
if (is_string($dir) && is_dir($dir)) {
|
|
foreach (glob($dir . '/*.yaml') ?: [] as $file) {
|
|
$localeCode = basename($file, '.yaml');
|
|
$dash = strpos($localeCode, '-');
|
|
if ($dash !== false) {
|
|
$index[substr($localeCode, 0, $dash)][] = $localeCode;
|
|
}
|
|
}
|
|
}
|
|
|
|
return $this->regionVariantIndex = $index;
|
|
}
|
|
|
|
/**
|
|
* Whitelist of callable patterns allowed by the resolve endpoint.
|
|
* Only static methods from known Grav namespaces are permitted.
|
|
*/
|
|
private const RESOLVE_ALLOWED_NAMESPACES = [
|
|
'Grav\\Common\\',
|
|
'Grav\\Plugin\\',
|
|
];
|
|
|
|
/**
|
|
* GET /data/resolve?callable=\Grav\Common\Page\Pages::pageTypes
|
|
*
|
|
* Generic endpoint for resolving data-options@ directives used in blueprints.
|
|
* Returns the array result of calling a whitelisted static PHP method.
|
|
* Client should cache responses — these are effectively static data.
|
|
*/
|
|
public function resolveData(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
$this->requirePermission($request, 'api.pages.read');
|
|
|
|
$query = $request->getQueryParams();
|
|
$callable = $query['callable'] ?? null;
|
|
|
|
if (!$callable || !is_string($callable)) {
|
|
throw new ValidationException(['callable' => ['The callable query parameter is required.']]);
|
|
}
|
|
|
|
$callable = ltrim($callable, '\\');
|
|
|
|
// Validate against whitelist
|
|
$allowed = false;
|
|
foreach (self::RESOLVE_ALLOWED_NAMESPACES as $ns) {
|
|
if (str_starts_with($callable, $ns)) {
|
|
$allowed = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!$allowed) {
|
|
throw new ValidationException(['callable' => ['Callable is not in the allowed namespace list.']]);
|
|
}
|
|
|
|
// Ensure Pages subsystem for Page-related callables
|
|
if (str_contains($callable, 'Page')) {
|
|
$this->ensurePagesEnabled();
|
|
}
|
|
|
|
if (!str_contains($callable, '::')) {
|
|
throw new ValidationException(['callable' => ['Callable must be in Class::method format.']]);
|
|
}
|
|
|
|
[$class, $method] = explode('::', $callable, 2);
|
|
$class = '\\' . $class;
|
|
|
|
if (!class_exists($class) || !method_exists($class, $method)) {
|
|
// admin-classic ships permission-filtered page-type wrappers
|
|
// (\Grav\Plugin\AdminPlugin::pagesTypes / ::pagesModularTypes).
|
|
// admin-next is designed to run without admin-classic, but a
|
|
// blueprint or a stale compiled-blueprint cache can still reference
|
|
// those callables — in which case the class isn't loaded and the
|
|
// hard guard below would 500 the template selector
|
|
// (grav-plugin-admin2#41). Fall back to core's always-available
|
|
// equivalent rather than throwing.
|
|
if (in_array($method, ['pagesTypes', 'pagesModularTypes'], true)) {
|
|
$type = $method === 'pagesModularTypes' ? 'modular' : 'standard';
|
|
return ApiResponse::create($this->normalizeOptions(Pages::pageTypes($type)));
|
|
}
|
|
|
|
throw new NotFoundException("Callable '{$callable}' not found.");
|
|
}
|
|
|
|
// For pageTypes(), pass the type arg so it returns standard or modular
|
|
if ($method === 'pageTypes') {
|
|
$type = $query['type'] ?? 'standard';
|
|
$result = $class::$method($type);
|
|
} else {
|
|
$result = $class::$method();
|
|
}
|
|
|
|
if (!is_array($result)) {
|
|
return ApiResponse::create([]);
|
|
}
|
|
|
|
return ApiResponse::create($this->normalizeOptions($result));
|
|
}
|
|
|
|
/**
|
|
* Normalize a [key => label] map to the [{value, label}] format the
|
|
* admin-next SelectField expects for `data-options@` results.
|
|
*
|
|
* @param array<string|int, mixed> $options
|
|
* @return list<array{value: string, label: string}>
|
|
*/
|
|
private function normalizeOptions(array $options): array
|
|
{
|
|
$normalized = [];
|
|
foreach ($options as $key => $label) {
|
|
$normalized[] = [
|
|
'value' => (string) $key,
|
|
'label' => is_string($label) ? $label : (string) $key,
|
|
];
|
|
}
|
|
|
|
return $normalized;
|
|
}
|
|
|
|
/**
|
|
* GET /blueprints/pages - List available page blueprints (templates).
|
|
*/
|
|
public function pageTypes(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
$this->requirePermission($request, 'api.pages.read');
|
|
$this->resolveBlueprintLanguages($request);
|
|
$this->ensurePagesEnabled();
|
|
|
|
// `?modular=true` returns modular templates (those whose Twig template
|
|
// file is prefixed with `_`, intended as sub-pages of a modular parent)
|
|
// instead of regular page templates. Mirrors the split classic admin
|
|
// makes between "Add Page" and "Add Module".
|
|
$params = $request->getQueryParams();
|
|
$modular = isset($params['modular'])
|
|
&& in_array(strtolower((string) $params['modular']), ['1', 'true', 'yes'], true);
|
|
|
|
$types = $modular ? Pages::modularTypes() : Pages::types();
|
|
$result = [];
|
|
|
|
foreach ($types as $type => $label) {
|
|
$result[] = [
|
|
'type' => $type,
|
|
'label' => is_string($label) ? $label : $type,
|
|
];
|
|
}
|
|
|
|
return ApiResponse::create($result);
|
|
}
|
|
|
|
/**
|
|
* GET /blueprints/pages/{template} - Get resolved blueprint for a page template.
|
|
*/
|
|
public function pageBlueprint(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
$this->requirePermission($request, 'api.pages.read');
|
|
$this->resolveBlueprintLanguages($request);
|
|
|
|
$template = $this->getRouteParam($request, 'template');
|
|
|
|
$blueprint = $this->loadPageBlueprint($template, $this->getUser($request));
|
|
|
|
if (!$blueprint) {
|
|
throw new NotFoundException("Blueprint for template '{$template}' not found.");
|
|
}
|
|
|
|
$data = $this->serializeBlueprint($blueprint, $template);
|
|
|
|
// Fire event to allow plugins to modify the serialized blueprint fields
|
|
// (e.g., editor-pro overrides editor/markdown field types). The
|
|
// explicit `context` discriminator lets listeners gate behavior to a
|
|
// specific blueprint family (e.g. ai-translate annotates only pages).
|
|
$event = new Event([
|
|
'context' => 'page',
|
|
'fields' => $data['fields'],
|
|
'template' => $template,
|
|
'user' => $this->getUser($request),
|
|
]);
|
|
$this->grav->fireEvent('onApiBlueprintResolved', $event);
|
|
$data['fields'] = $event['fields'];
|
|
|
|
return ApiResponse::create($data);
|
|
}
|
|
|
|
/**
|
|
* GET /blueprints/plugins/{plugin} - Get resolved blueprint for a plugin.
|
|
*/
|
|
public function pluginBlueprint(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
$this->requirePermission($request, 'api.config.read');
|
|
$this->resolveBlueprintLanguages($request);
|
|
|
|
$pluginName = $this->getRouteParam($request, 'plugin');
|
|
$pluginPath = $this->grav['locator']->findResource("plugin://{$pluginName}");
|
|
|
|
if (!$pluginPath || !file_exists($pluginPath . '/blueprints.yaml')) {
|
|
throw new NotFoundException("Blueprint for plugin '{$pluginName}' not found.");
|
|
}
|
|
|
|
$blueprint = new Blueprint($pluginPath . '/blueprints.yaml');
|
|
$blueprint->load();
|
|
|
|
$data = $this->serializeBlueprint($blueprint, $pluginName);
|
|
|
|
// Fire event to allow plugins to modify serialized fields
|
|
$event = new Event([
|
|
'context' => 'plugin',
|
|
'fields' => $data['fields'],
|
|
'plugin' => $pluginName,
|
|
'user' => $this->getUser($request),
|
|
]);
|
|
$this->grav->fireEvent('onApiBlueprintResolved', $event);
|
|
$data['fields'] = $event['fields'];
|
|
|
|
return ApiResponse::create($data);
|
|
}
|
|
|
|
/**
|
|
* GET /blueprints/themes/{theme} - Get resolved blueprint for a theme.
|
|
*/
|
|
public function themeBlueprint(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
$this->requirePermission($request, 'api.config.read');
|
|
$this->resolveBlueprintLanguages($request);
|
|
|
|
$themeName = $this->getRouteParam($request, 'theme');
|
|
$themesPath = $this->grav['locator']->findResource('themes://');
|
|
$themePath = $themesPath . '/' . $themeName;
|
|
|
|
if (!is_dir($themePath) || !file_exists($themePath . '/blueprints.yaml')) {
|
|
throw new NotFoundException("Blueprint for theme '{$themeName}' not found.");
|
|
}
|
|
|
|
$blueprint = new Blueprint($themePath . '/blueprints.yaml');
|
|
$blueprint->load();
|
|
|
|
$data = $this->serializeBlueprint($blueprint, $themeName);
|
|
|
|
// Fire event so plugins can extend / annotate theme blueprints, with
|
|
// an explicit `context` discriminator so listeners (e.g. ai-translate)
|
|
// can scope behavior to a specific blueprint family.
|
|
$event = new Event([
|
|
'context' => 'theme',
|
|
'fields' => $data['fields'],
|
|
'theme' => $themeName,
|
|
'user' => $this->getUser($request),
|
|
]);
|
|
$this->grav->fireEvent('onApiBlueprintResolved', $event);
|
|
$data['fields'] = $event['fields'];
|
|
|
|
return ApiResponse::create($data);
|
|
}
|
|
|
|
/**
|
|
* GET /blueprints/users - Get the user account blueprint.
|
|
*/
|
|
public function userBlueprint(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
// The user blueprint is just the form schema, not user data — every
|
|
// authenticated user needs it to render their own profile form, even
|
|
// those without api.users.read.
|
|
$this->requirePermission($request, 'api.access');
|
|
$this->resolveBlueprintLanguages($request);
|
|
|
|
$blueprintPath = $this->grav['locator']->findResource('blueprints://user/account.yaml');
|
|
|
|
if (!$blueprintPath) {
|
|
$blueprintPath = $this->grav['locator']->findResource('system://blueprints/user/account.yaml');
|
|
}
|
|
|
|
if (!$blueprintPath) {
|
|
throw new NotFoundException('User account blueprint not found.');
|
|
}
|
|
|
|
$blueprint = new Blueprint($blueprintPath);
|
|
$blueprint->load();
|
|
|
|
$data = $this->serializeBlueprint($blueprint, 'account');
|
|
|
|
// Fire event so plugins can extend the user blueprint (e.g. admin2
|
|
// injects the account-state toggle, since core's account.yaml has
|
|
// no field for it).
|
|
$event = new Event([
|
|
'context' => 'account',
|
|
'fields' => $data['fields'],
|
|
'template' => 'account',
|
|
'user' => $this->getUser($request),
|
|
]);
|
|
$this->grav->fireEvent('onApiBlueprintResolved', $event);
|
|
$data['fields'] = $event['fields'];
|
|
|
|
return ApiResponse::create($data);
|
|
}
|
|
|
|
/**
|
|
* GET /blueprints/groups - User group edit blueprint (user/group.yaml).
|
|
*/
|
|
public function groupBlueprint(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
return $this->loadGroupBlueprint($request, 'group', 'group');
|
|
}
|
|
|
|
/**
|
|
* GET /blueprints/groups/new - User group creation blueprint (user/group_new.yaml).
|
|
*/
|
|
public function groupNewBlueprint(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
return $this->loadGroupBlueprint($request, 'group_new', 'group_new');
|
|
}
|
|
|
|
private function loadGroupBlueprint(
|
|
ServerRequestInterface $request,
|
|
string $name,
|
|
string $context,
|
|
): ResponseInterface {
|
|
$this->requirePermission($request, 'api.users.read');
|
|
$this->resolveBlueprintLanguages($request);
|
|
|
|
$path = $this->grav['locator']->findResource("blueprints://user/{$name}.yaml")
|
|
?: $this->grav['locator']->findResource("system://blueprints/user/{$name}.yaml");
|
|
|
|
if (!$path) {
|
|
throw new NotFoundException("Group blueprint '{$name}' not found.");
|
|
}
|
|
|
|
$blueprint = new Blueprint($path);
|
|
$blueprint->load();
|
|
|
|
$data = $this->serializeBlueprint($blueprint, $name);
|
|
|
|
$event = new Event([
|
|
'context' => $context,
|
|
'fields' => $data['fields'],
|
|
'template' => $name,
|
|
'user' => $this->getUser($request),
|
|
]);
|
|
$this->grav->fireEvent('onApiBlueprintResolved', $event);
|
|
$data['fields'] = $event['fields'];
|
|
|
|
return ApiResponse::create($data);
|
|
}
|
|
|
|
/**
|
|
* GET /blueprints/config/accounts - Flex accounts configuration blueprint
|
|
* (the form behind the "Configuration" tab on the Users page).
|
|
*
|
|
* Delegates to FlexDirectory::getDirectoryBlueprint() — the same code path
|
|
* admin-classic uses. That loads blueprints://flex/shared/configure.yaml
|
|
* (the Caching tab) as the base and embeds the user-accounts blueprint's
|
|
* `blueprints.configure.fields` (Compatibility tab via import@) as sibling
|
|
* tabs. Reimplementing this by hand would silently drop the Caching tab
|
|
* (the shared form isn't reachable from the user-accounts blueprint alone).
|
|
*/
|
|
public function accountsConfigBlueprint(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
$this->requirePermission($request, 'api.config.read');
|
|
$this->resolveBlueprintLanguages($request);
|
|
|
|
$flex = $this->grav['flex_objects'] ?? null;
|
|
if (!$flex) {
|
|
throw new NotFoundException('Flex Objects is not available — Accounts configuration requires it.');
|
|
}
|
|
|
|
$directory = $flex->getDirectory('user-accounts');
|
|
if (!$directory) {
|
|
throw new NotFoundException('user-accounts flex directory is not registered.');
|
|
}
|
|
|
|
$blueprint = $directory->getDirectoryBlueprint();
|
|
|
|
$data = $this->serializeBlueprint($blueprint, 'accounts');
|
|
if (empty($data['title'])) {
|
|
$data['title'] = 'Accounts Configuration';
|
|
}
|
|
|
|
$event = new Event([
|
|
'context' => 'config',
|
|
'fields' => $data['fields'],
|
|
'template' => 'accounts',
|
|
'user' => $this->getUser($request),
|
|
]);
|
|
$this->grav->fireEvent('onApiBlueprintResolved', $event);
|
|
$data['fields'] = $event['fields'];
|
|
|
|
return ApiResponse::create($data);
|
|
}
|
|
|
|
/**
|
|
* GET /blueprints/users/permissions - Get all registered permission actions.
|
|
*/
|
|
public function permissionsBlueprint(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
$this->requirePermission($request, 'api.users.read');
|
|
$this->resolveBlueprintLanguages($request);
|
|
|
|
/** @var \Grav\Framework\Acl\Permissions $permissions */
|
|
$permissions = $this->grav['permissions'];
|
|
|
|
$sections = [];
|
|
foreach ($permissions as $name => $action) {
|
|
$sections[] = $this->serializePermissionAction($action, $name);
|
|
}
|
|
|
|
return ApiResponse::create($sections);
|
|
}
|
|
|
|
/**
|
|
* Recursively serialize a permission action and its children.
|
|
*/
|
|
private function serializePermissionAction(object $action, string $name): array
|
|
{
|
|
$rawLabel = $action->label ?? $name;
|
|
$label = $this->translateLabel($rawLabel);
|
|
|
|
$data = [
|
|
'name' => $name,
|
|
'label' => $label,
|
|
];
|
|
|
|
// Check for child actions
|
|
$children = [];
|
|
if ($action instanceof \IteratorAggregate || $action instanceof \Traversable) {
|
|
foreach ($action as $child) {
|
|
// Use $child->name which has the full dotted path (e.g. "admin.login")
|
|
$children[] = $this->serializePermissionAction($child, $child->name ?? $name);
|
|
}
|
|
}
|
|
|
|
if ($children) {
|
|
$data['children'] = $children;
|
|
}
|
|
|
|
return $data;
|
|
}
|
|
|
|
/**
|
|
* Translate a blueprint / permission label string.
|
|
*
|
|
* Lookup order, ICU-first:
|
|
* 1. `ICU.<key>` — admin2's authoritative namespace (Grav 2 convention).
|
|
* 2. `<key>` — flat lookup, for legacy plugins that ship PLUGIN_ADMIN.*
|
|
* under the Grav 1 convention (form, login, flex-objects, etc.).
|
|
* 3. `PLUGIN_API.<last-segment>` — last-resort api-plugin namespace.
|
|
* 4. Humanizer over the key itself.
|
|
*
|
|
* ICU is checked first by design: admin classic's plugin folder may still
|
|
* be present in dev installs (disabled, mid-migration) and Grav core's
|
|
* `flattenByLang()` reads every plugin's lang files regardless of enabled
|
|
* state. Without the ICU-first order, admin classic's flat values would
|
|
* shadow admin2's ICU ports — a per-key drift that's hard to spot. Putting
|
|
* ICU first makes admin2 the source of truth for any key it ships, and
|
|
* lets the flat lookup serve as a transition fallback for keys admin2
|
|
* hasn't ported (or that legitimate 3rd-party plugins ship under
|
|
* PLUGIN_ADMIN.* for shared-vocabulary labels).
|
|
*/
|
|
protected function translateLabel(string $label): string
|
|
{
|
|
$lang = $this->grav['language'];
|
|
// Use the per-request language chain (set by serializeBlueprint /
|
|
// pageTypes / etc.) so labels resolve against the user's chosen
|
|
// admin language, not the site's default content language. Falls
|
|
// back to no override when no endpoint primed the chain — that
|
|
// preserves Grav's normal lookup behaviour for any non-blueprint
|
|
// caller (e.g. test code) that calls translateLabel() directly.
|
|
$languages = $this->blueprintLanguages;
|
|
$primary = $languages[0] ?? ($lang->getLanguage() ?: 'en');
|
|
|
|
// If it looks like a language key (e.g. PLUGIN_ADMIN.ACCESS_SITE), try to translate
|
|
if (str_contains($label, '.') && strtoupper($label) === $label) {
|
|
$icuKey = 'ICU.' . $label;
|
|
$icuTranslated = $lang->translate($icuKey, $languages);
|
|
if ($icuTranslated !== $icuKey) {
|
|
return $icuTranslated;
|
|
}
|
|
|
|
// admin2 consolidated its shared PLUGIN_ADMIN vocabulary into the
|
|
// ICU.ADMIN_NEXT namespace so the translation service — scoped to
|
|
// ADMIN_NEXT — actually translates it into every locale. Blueprints
|
|
// (and 160+ plugins) still reference the public PLUGIN_ADMIN.* keys,
|
|
// so alias them onto ICU.ADMIN_NEXT.* here. A handful of nav-word
|
|
// keys (GROUPS/MEDIA/PAGES/SETTINGS/SYSTEM) resolve to a nested map
|
|
// under ADMIN_NEXT rather than a string; the is_string guard lets
|
|
// those fall through to the humaniser (which yields the right word).
|
|
if (str_starts_with($label, 'PLUGIN_ADMIN.')) {
|
|
$aliasKey = 'ICU.ADMIN_NEXT.' . substr($label, strlen('PLUGIN_ADMIN.'));
|
|
// array_support=true returns the raw node instead of casting an
|
|
// array to string, so a key that lands on a nested namespace
|
|
// (GROUPS/MEDIA/PAGES/SETTINGS/SYSTEM) comes back as an array and
|
|
// is skipped here rather than blowing up on "Array to string".
|
|
$aliasTranslated = $lang->translate($aliasKey, $languages, true);
|
|
if (is_string($aliasTranslated) && $aliasTranslated !== $aliasKey) {
|
|
return $aliasTranslated;
|
|
}
|
|
}
|
|
|
|
// Skip the flat lookup if the only source for this key is a disabled
|
|
// plugin — a disabled plugin shouldn't influence what admin2 renders.
|
|
if (!$this->disabledLangIndex()->isDisabledOnly($label, $primary)) {
|
|
$translated = $lang->translate($label, $languages);
|
|
if ($translated !== $label) {
|
|
return $translated;
|
|
}
|
|
}
|
|
|
|
// Try API plugin namespace as fallback
|
|
$key = substr($label, strrpos($label, '.') + 1);
|
|
$apiTranslated = $lang->translate('PLUGIN_API.' . $key, $languages);
|
|
if ($apiTranslated !== 'PLUGIN_API.' . $key) {
|
|
return $apiTranslated;
|
|
}
|
|
}
|
|
|
|
// If the label is still a raw key, derive a human-readable name from the permission name
|
|
if (strtoupper($label) === $label && str_contains($label, '_')) {
|
|
// PLUGIN_ADMIN.ACCESS_ADMIN_CONFIGURATION -> Configuration
|
|
$parts = explode('.', $label);
|
|
$last = end($parts);
|
|
// Remove ACCESS_ prefix
|
|
$last = preg_replace('/^ACCESS_(?:ADMIN_|SITE_)?/', '', $last);
|
|
return ucwords(strtolower(str_replace('_', ' ', $last)));
|
|
}
|
|
|
|
return $label;
|
|
}
|
|
|
|
/**
|
|
* GET /blueprints/plugins/{plugin}/pages/{pageId} - Get custom page blueprint for a plugin.
|
|
*/
|
|
public function pluginPageBlueprint(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
$this->requirePermission($request, 'api.config.read');
|
|
$this->resolveBlueprintLanguages($request);
|
|
|
|
$plugin = $this->getRouteParam($request, 'plugin');
|
|
$pageId = $this->getRouteParam($request, 'pageId');
|
|
|
|
$pluginPath = $this->grav['locator']->findResource("plugin://{$plugin}");
|
|
|
|
if (!$pluginPath) {
|
|
throw new NotFoundException("Plugin '{$plugin}' not found.");
|
|
}
|
|
|
|
$blueprintFile = $pluginPath . '/admin/blueprints/' . basename($pageId) . '.yaml';
|
|
|
|
// Fallback: when the dedicated admin/blueprints/{pageId}.yaml is missing
|
|
// and the page id matches the plugin slug, treat the plugin's main
|
|
// blueprints.yaml as the page blueprint. Lets plugins whose admin-next
|
|
// settings page is just the existing plugin form skip maintaining a
|
|
// duplicate YAML — algolia-pro keeps its dedicated page blueprint, but
|
|
// simpler plugins (git-sync) reuse the one they already have.
|
|
if (!file_exists($blueprintFile) && $pageId === $plugin && file_exists($pluginPath . '/blueprints.yaml')) {
|
|
$blueprintFile = $pluginPath . '/blueprints.yaml';
|
|
}
|
|
|
|
if (!file_exists($blueprintFile)) {
|
|
throw new NotFoundException("Page blueprint '{$pageId}' not found for plugin '{$plugin}'.");
|
|
}
|
|
|
|
$blueprint = new Blueprint($blueprintFile);
|
|
$blueprint->load();
|
|
|
|
$data = $this->serializeBlueprint($blueprint, $pageId);
|
|
|
|
// Fire event so plugins (notably flex-objects) can extend plugin
|
|
// page blueprints — e.g. inject the shared Flex configure tabs
|
|
// (Caching) when the owning plugin manages a Flex directory.
|
|
$event = new Event([
|
|
'context' => 'plugin-page',
|
|
'fields' => $data['fields'],
|
|
'plugin' => $plugin,
|
|
'page_id' => $pageId,
|
|
'user' => $this->getUser($request),
|
|
]);
|
|
$this->grav->fireEvent('onApiBlueprintResolved', $event);
|
|
$data['fields'] = $event['fields'];
|
|
|
|
return ApiResponse::create($data);
|
|
}
|
|
|
|
/**
|
|
* GET /blueprints/config/{scope} - Get blueprint for system/site config.
|
|
*/
|
|
public function configBlueprint(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
$this->requirePermission($request, 'api.config.read');
|
|
$this->resolveBlueprintLanguages($request);
|
|
|
|
$scope = $this->getRouteParam($request, 'scope');
|
|
|
|
// Core scopes ship system blueprints; custom scopes are site-authored
|
|
// top-level configs (the cookbook "add a custom yaml file" recipe). Any
|
|
// other scope — including core/system blueprints like `streams` — is
|
|
// rejected. See {@see ConfigScopes::isCustom()} for the security gate.
|
|
if (!in_array($scope, ConfigScopes::CORE, true) && !ConfigScopes::isCustom($this->grav, $scope)) {
|
|
throw new NotFoundException("Config blueprint scope '{$scope}' not found.");
|
|
}
|
|
|
|
// Use the blueprints:// stream to find config blueprints so that
|
|
// plugin overrides (e.g., admin's media.yaml) are resolved correctly.
|
|
$realPath = $this->grav['locator']->findResource("blueprints://config/{$scope}.yaml");
|
|
|
|
if (!$realPath) {
|
|
// Fallback to system blueprints directly
|
|
$realPath = $this->grav['locator']->findResource("system://blueprints/config/{$scope}.yaml");
|
|
}
|
|
|
|
if (!$realPath) {
|
|
throw new NotFoundException("Config blueprint for '{$scope}' not found.");
|
|
}
|
|
|
|
$blueprint = new Blueprint($realPath);
|
|
$blueprint->load();
|
|
|
|
return ApiResponse::create($this->serializeBlueprint($blueprint, $scope));
|
|
}
|
|
|
|
/**
|
|
* Load a fully-resolved page blueprint via Grav core's standard pipeline.
|
|
*
|
|
* Delegates to Pages::blueprints() (= Blueprints::loadFile() → Blueprint::load()->init())
|
|
* — the same path admin-classic uses. This honors every BlueprintForm
|
|
* directive (replace@, unset@, replace-<prop>@, ordering@, import@ with
|
|
* inline insertion, @extends with context, config-default@, etc.), and
|
|
* fires onBlueprintCreated so plugins can extend the result.
|
|
*
|
|
* Earlier versions hand-rolled YAML merging here to dodge a perceived
|
|
* memory-exhaustion risk in the full pipeline. In practice Grav core
|
|
* runs this code on every page edit in admin-classic without trouble,
|
|
* and the hand-rolled path silently dropped most BlueprintForm directives
|
|
* (see grav-plugin-admin2#3).
|
|
*/
|
|
private function loadPageBlueprint(string $template, ?UserInterface $user = null): ?Blueprint
|
|
{
|
|
$this->ensurePagesEnabled();
|
|
|
|
/** @var Pages $pages */
|
|
$pages = $this->grav['pages'];
|
|
|
|
try {
|
|
$blueprint = $pages->blueprints($template);
|
|
} catch (\RuntimeException) {
|
|
return null;
|
|
}
|
|
|
|
// An orphan template — one with no blueprint of its own, e.g. a page
|
|
// left on a template that the current theme doesn't define after a
|
|
// theme switch — resolves to an empty blueprint with no fields. Grav
|
|
// core only falls back to `default` when the lookup *throws*, which a
|
|
// missing blueprint file does not: it returns the empty blueprint
|
|
// instead. Mirror admin-classic and fall back to the default page
|
|
// blueprint so the editor always shows the standard page form rather
|
|
// than a blank pane.
|
|
if (!$blueprint->fields()) {
|
|
try {
|
|
$blueprint = $pages->blueprints('default');
|
|
} catch (\RuntimeException) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
$this->injectSecurityTab($blueprint, $user);
|
|
|
|
return $blueprint;
|
|
}
|
|
|
|
/**
|
|
* Inject the page Security tab into a resolved page blueprint.
|
|
*
|
|
* Page-type blueprints (default.yaml etc.) don't carry the Security tab —
|
|
* in admin-classic it's the Flex pages wrapper (blueprints://flex/pages.yaml)
|
|
* that adds it via `import@: { type: partials/security }`. Admin-next loads
|
|
* the plain page-type blueprint instead, so the tab goes missing. We
|
|
* replicate the Flex wrapper here: load the same security partial and embed
|
|
* it as a tab, positioned right after `advanced` to match classic ordering.
|
|
*
|
|
* The partial only sets frontmatter (header.access, header.permissions.*)
|
|
* that grav-core already understands — nothing else changes.
|
|
*
|
|
* The partial's `_admin` (Page Permissions) section carries a
|
|
* `security@: {or: [admin.super, admin.configuration.pages]}` gate. Core
|
|
* evaluates that against `$grav['user']`, but during an API request that's
|
|
* the guest user — so the gate fails for everyone and stamps the section
|
|
* with `validate: ignore`. We evaluate the gate ourselves against the real
|
|
* authenticated API user, accepting the API authority equivalents
|
|
* (api.super / api.config): authorized users get the section clean and
|
|
* editable, everyone else only sees the ungated Page Access section.
|
|
*/
|
|
private function injectSecurityTab(Blueprint $blueprint, ?UserInterface $user = null): void
|
|
{
|
|
// Only page blueprints that wrap their fields in a `tabs` container can
|
|
// host the Security tab. Skip anything with a different layout.
|
|
$tabs = $blueprint->get('form/fields/tabs');
|
|
if (!is_array($tabs) || ($tabs['type'] ?? null) !== 'tabs') {
|
|
return;
|
|
}
|
|
|
|
// Respect a template/plugin that already defines its own Security tab.
|
|
if ($blueprint->get('form/fields/tabs/fields/security') !== null) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$security = new Blueprint('partials/security');
|
|
$security->setContext('blueprints://pages');
|
|
$security->load()->init();
|
|
} catch (Throwable) {
|
|
return;
|
|
}
|
|
|
|
$securityFields = $security->fields();
|
|
if (empty($securityFields)) {
|
|
return;
|
|
}
|
|
|
|
// Gate the Page Permissions section on API authority. `_site` (Page
|
|
// Access) is ungated and always shown.
|
|
$canManagePermissions = $user !== null
|
|
&& ($this->isSuperAdmin($user) || $this->hasPermission($user, 'api.config'));
|
|
|
|
if (isset($securityFields['_admin'])) {
|
|
if ($canManagePermissions) {
|
|
// Clear the guest-induced `validate: ignore` so the section is
|
|
// fully editable (baseline has no ignore flags of its own).
|
|
$this->clearValidateIgnore($securityFields['_admin']);
|
|
} else {
|
|
unset($securityFields['_admin']);
|
|
}
|
|
}
|
|
|
|
if (empty($securityFields)) {
|
|
return;
|
|
}
|
|
|
|
// Turn the two `acl_picker` fields (Page Access, Page Groups) into the
|
|
// dedicated admin-next web components with their dropdown options baked
|
|
// in server-side. See decorateAclPickerFields() for why.
|
|
$this->decorateAclPickerFields($securityFields);
|
|
|
|
$securityTab = [
|
|
'type' => 'tab',
|
|
'title' => 'PLUGIN_ADMIN.SECURITY',
|
|
'fields' => $securityFields,
|
|
];
|
|
|
|
// Insert after the core `advanced` tab so the order matches classic
|
|
// (Content, Options, Advanced, Security, …plugin tabs). Fall back to
|
|
// appending if no `advanced` tab is present.
|
|
$rebuilt = [];
|
|
$inserted = false;
|
|
foreach ((array) ($tabs['fields'] ?? []) as $key => $value) {
|
|
$rebuilt[$key] = $value;
|
|
if ($key === 'advanced') {
|
|
$rebuilt['security'] = $securityTab;
|
|
$inserted = true;
|
|
}
|
|
}
|
|
if (!$inserted) {
|
|
$rebuilt['security'] = $securityTab;
|
|
}
|
|
|
|
$blueprint->set('form/fields/tabs/fields', $rebuilt);
|
|
}
|
|
|
|
/**
|
|
* Recursively remove the `validate: ignore` flag that core's blueprint
|
|
* init stamps on a `security@`-gated field (and its children) when the
|
|
* gate fails. Leaves the rest of each `validate` block intact.
|
|
*/
|
|
private function clearValidateIgnore(array &$field): void
|
|
{
|
|
if (isset($field['validate']) && is_array($field['validate'])) {
|
|
unset($field['validate']['ignore']);
|
|
if ($field['validate'] === []) {
|
|
unset($field['validate']);
|
|
}
|
|
}
|
|
|
|
if (isset($field['fields']) && is_array($field['fields'])) {
|
|
foreach ($field['fields'] as &$child) {
|
|
if (is_array($child)) {
|
|
$this->clearValidateIgnore($child);
|
|
}
|
|
}
|
|
unset($child);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Replace the page security `acl_picker` fields with their admin-next web
|
|
* components and bake their dropdown options in server-side.
|
|
*
|
|
* admin-next's native FieldRenderer claims `acl_picker` before the custom
|
|
* field registry, and `data_type` (access vs permissions) isn't part of
|
|
* the serialized field props — so a stock `acl_picker` can't render the
|
|
* classic row picker. We remap each field to a distinct custom type that
|
|
* falls through to the plugin web component:
|
|
* - data_type: access → `acl-access` (Allowed/Denied per action)
|
|
* - data_type: permissions → `acl-permissions` (CRUD per group)
|
|
*
|
|
* The option lists (access actions / user groups) need `$grav['permissions']`
|
|
* and the groups directory, and the access-actions endpoint is gated on
|
|
* `api.users.read` which a page editor may not hold — so we resolve them
|
|
* here and attach as `options`, sparing the component an extra (possibly
|
|
* forbidden) round-trip.
|
|
*/
|
|
private function decorateAclPickerFields(array &$fields): void
|
|
{
|
|
foreach ($fields as $key => &$field) {
|
|
if (!is_array($field)) {
|
|
continue;
|
|
}
|
|
|
|
$type = $field['type'] ?? null;
|
|
|
|
if ($type === 'acl_picker') {
|
|
$dataType = $field['data_type'] ?? null;
|
|
if ($dataType === 'access') {
|
|
$field['type'] = 'acl-access';
|
|
$field['options'] = $this->buildAccessActionOptions();
|
|
} elseif ($dataType === 'permissions') {
|
|
$field['type'] = 'acl-permissions';
|
|
$field['options'] = $this->buildGroupOptions();
|
|
}
|
|
unset($field['data_type']);
|
|
}
|
|
|
|
if (isset($field['fields']) && is_array($field['fields'])) {
|
|
$this->decorateAclPickerFields($field['fields']);
|
|
}
|
|
}
|
|
unset($field);
|
|
}
|
|
|
|
/**
|
|
* Resolve the option list for a `users` field — every account that meets
|
|
* the field's access/group requirements. Config props on the field:
|
|
*
|
|
* access: api.pages.write # min permission (string or list, any-of)
|
|
* groups: [editors, authors] # group membership (string or list, any-of)
|
|
*
|
|
* With neither set, every account is listed. Super admins (API or classic)
|
|
* always qualify. The value stored is the username, so existing plain
|
|
* username-array fields round-trip unchanged.
|
|
*
|
|
* @return array<string, string> username => label, insertion order preserved
|
|
*/
|
|
private function resolveUserFieldOptions(array $field): array
|
|
{
|
|
$accessList = $this->toStringList($field['access'] ?? null);
|
|
$groupList = $this->toStringList($field['groups'] ?? null);
|
|
|
|
$options = [];
|
|
try {
|
|
$accounts = $this->grav['accounts'] ?? null;
|
|
if (!$accounts) {
|
|
return $options;
|
|
}
|
|
foreach ($this->getAccountUsernames() as $username) {
|
|
$account = $accounts->load($username);
|
|
if (!$account || !$account->exists()) {
|
|
continue;
|
|
}
|
|
if (!$this->userMeetsRequirements($account, $accessList, $groupList)) {
|
|
continue;
|
|
}
|
|
$fullname = (string) ($account->get('fullname') ?? '');
|
|
$options[(string) $username] = $fullname !== ''
|
|
? sprintf('%s (%s)', $fullname, $username)
|
|
: (string) $username;
|
|
}
|
|
} catch (Throwable) {
|
|
// Fall through with whatever was collected.
|
|
}
|
|
|
|
return $options;
|
|
}
|
|
|
|
/**
|
|
* Whether an account satisfies a `users` field's access/group filter.
|
|
* Empty filter → everyone qualifies; super admins always qualify.
|
|
*
|
|
* @param list<string> $accessList
|
|
* @param list<string> $groupList
|
|
*/
|
|
private function userMeetsRequirements(object $account, array $accessList, array $groupList): bool
|
|
{
|
|
if (!$accessList && !$groupList) {
|
|
return true;
|
|
}
|
|
if ($this->isSuperAdmin($account) || (bool) $account->get('access.admin.super')) {
|
|
return true;
|
|
}
|
|
foreach ($accessList as $permission) {
|
|
if ($this->hasPermission($account, $permission)) {
|
|
return true;
|
|
}
|
|
}
|
|
if ($groupList) {
|
|
$userGroups = (array) $account->get('groups', []);
|
|
foreach ($groupList as $group) {
|
|
if (in_array($group, $userGroups, true)) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Normalize a scalar-or-list blueprint config value into a list of
|
|
* non-empty strings.
|
|
*
|
|
* @return list<string>
|
|
*/
|
|
private function toStringList(mixed $value): array
|
|
{
|
|
if ($value === null || $value === '') {
|
|
return [];
|
|
}
|
|
return array_values(array_filter(
|
|
array_map(static fn ($v) => (string) $v, (array) $value),
|
|
static fn (string $s) => $s !== '',
|
|
));
|
|
}
|
|
|
|
/**
|
|
* Enumerate user-account usernames from the accounts storage directory.
|
|
* Mirrors UsersController's listing without depending on its private API.
|
|
*
|
|
* @return list<string>
|
|
*/
|
|
private function getAccountUsernames(): array
|
|
{
|
|
$locator = $this->grav['locator'];
|
|
$dir = $locator->findResource('account://', true) ?: $locator->findResource('user://accounts', true);
|
|
if (!$dir || !is_dir($dir)) {
|
|
return [];
|
|
}
|
|
|
|
$usernames = [];
|
|
foreach (new \DirectoryIterator($dir) as $file) {
|
|
if ($file->isDot() || !$file->isFile() || $file->getExtension() !== 'yaml') {
|
|
continue;
|
|
}
|
|
$usernames[] = $file->getBasename('.yaml');
|
|
}
|
|
sort($usernames);
|
|
|
|
return $usernames;
|
|
}
|
|
|
|
/**
|
|
* Build the Page Access dropdown options from the registered ACL actions,
|
|
* e.g. `admin.login` → "Login to Admin (admin.login)". Mirrors the
|
|
* `data_type: access` option list in admin-classic's acl_picker.
|
|
*
|
|
* @return array<string, string> value => label, insertion order preserved
|
|
*/
|
|
private function buildAccessActionOptions(): array
|
|
{
|
|
$options = [];
|
|
try {
|
|
$permissions = $this->grav['permissions'] ?? null;
|
|
if ($permissions && method_exists($permissions, 'getInstances')) {
|
|
foreach ($permissions->getInstances() as $action) {
|
|
$name = $action->name ?? null;
|
|
if (!$name || ($action->visible ?? true) === false) {
|
|
continue;
|
|
}
|
|
// Short label only — the picker shows the dotted action
|
|
// name (the option value) as secondary text and derives the
|
|
// tree nesting from it.
|
|
$options[(string) $name] = $this->translateLabel($action->label ?? $name);
|
|
}
|
|
}
|
|
} catch (Throwable) {
|
|
// Fall through with whatever was collected.
|
|
}
|
|
|
|
return $options;
|
|
}
|
|
|
|
/**
|
|
* Build the Page Groups dropdown options: every user group plus the two
|
|
* special ACL targets that grav-core understands for pages. Mirrors the
|
|
* `data_type: permissions` option list in admin-classic's acl_picker.
|
|
*
|
|
* @return array<string, string> value => label, insertion order preserved
|
|
*/
|
|
private function buildGroupOptions(): array
|
|
{
|
|
$options = [];
|
|
|
|
try {
|
|
$flex = $this->grav['flex'] ?? $this->grav['flex_objects'] ?? null;
|
|
$directory = $flex && method_exists($flex, 'getDirectory') ? $flex->getDirectory('user-groups') : null;
|
|
if ($directory) {
|
|
foreach ($directory->getCollection() as $key => $group) {
|
|
$name = (is_object($group) && method_exists($group, 'get') ? $group->get('groupname') : null) ?: (string) $key;
|
|
$label = (is_object($group) && method_exists($group, 'get') ? $group->get('readableName') : null) ?: $name;
|
|
$options[(string) $name] = (string) $label;
|
|
}
|
|
}
|
|
} catch (Throwable) {
|
|
// Fall through to config-based enumeration.
|
|
}
|
|
|
|
if (!$options) {
|
|
foreach ((array) $this->grav['config']->get('groups', []) as $name => $group) {
|
|
$label = is_array($group) ? ($group['readableName'] ?? $name) : $name;
|
|
$options[(string) $name] = (string) $label;
|
|
}
|
|
}
|
|
|
|
// Special ACL targets understood by grav-core for page permissions.
|
|
$options['authors'] = $this->translateLabel('PLUGIN_ADMIN.PAGE_AUTHORS') . ' (Special)';
|
|
$options['defaults'] = 'Default ACL (Special)';
|
|
|
|
return $options;
|
|
}
|
|
|
|
/**
|
|
* Ensure the Pages subsystem is initialized.
|
|
* Many data-options@ directives reference Pages:: methods that need this.
|
|
*/
|
|
protected function ensurePagesEnabled(): void
|
|
{
|
|
if ($this->pagesEnabled) {
|
|
return;
|
|
}
|
|
$pages = $this->grav['pages'];
|
|
if (method_exists($pages, 'enablePages')) {
|
|
$pages->enablePages();
|
|
}
|
|
$this->pagesEnabled = true;
|
|
}
|
|
|
|
protected bool $pagesEnabled = false;
|
|
|
|
/**
|
|
* Resolve a data-*@ directive by calling the referenced PHP callable.
|
|
* Supports format: '\Grav\Common\Utils::timezones' or ['method', 'args']
|
|
*/
|
|
protected function resolveDataDirective(mixed $directive): ?array
|
|
{
|
|
try {
|
|
$callable = is_array($directive) ? ($directive[0] ?? null) : $directive;
|
|
if (!is_string($callable)) {
|
|
return null;
|
|
}
|
|
|
|
$callable = ltrim($callable, '\\');
|
|
|
|
// Parse Class::method format
|
|
if (str_contains($callable, '::')) {
|
|
[$class, $method] = explode('::', $callable, 2);
|
|
$class = '\\' . $class;
|
|
|
|
// Ensure Pages subsystem is available for Page-related callables
|
|
if (str_contains($class, 'Page')) {
|
|
$this->ensurePagesEnabled();
|
|
}
|
|
|
|
if (class_exists($class) && method_exists($class, $method)) {
|
|
// pageTypes() needs a type arg. Use the current serialization
|
|
// context (modular if we're serializing a `modular/*` blueprint,
|
|
// standard otherwise) so the template selector gets the right
|
|
// list baked in.
|
|
if ($method === 'pageTypes') {
|
|
$result = $class::$method($this->pageTypeContext);
|
|
} else {
|
|
$result = $class::$method();
|
|
}
|
|
return is_array($result) ? $result : null;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
} catch (\Throwable) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Serialize a Blueprint object into a JSON-friendly structure.
|
|
*/
|
|
/**
|
|
* Page-type context for the current serialization pass. Read by
|
|
* resolveDataDirective() when expanding `Pages::pageTypes` so a modular
|
|
* template's blueprint gets the modular template list instead of the
|
|
* default 'standard' list.
|
|
*/
|
|
private string $pageTypeContext = 'standard';
|
|
|
|
protected function serializeBlueprint(Blueprint $blueprint, string $name): array
|
|
{
|
|
$form = $blueprint->form();
|
|
$fields = $blueprint->fields();
|
|
|
|
// Modular page templates live under `modular/` (e.g. `modular/hero`).
|
|
// Track this so Pages::pageTypes resolves to the modular list for the
|
|
// template field instead of the standard list.
|
|
$this->pageTypeContext = str_starts_with($name, 'modular/') ? 'modular' : 'standard';
|
|
|
|
return [
|
|
'name' => $name,
|
|
'title' => $form['title'] ?? $blueprint->get('name') ?? $name,
|
|
'type' => $blueprint->get('type') ?? null,
|
|
'child_type' => $blueprint->get('child_type') ?? null,
|
|
'validation' => $form['validation'] ?? 'loose',
|
|
'fields' => $this->serializeFields($fields),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Recursively serialize blueprint fields into a structure
|
|
* suitable for client-side form rendering.
|
|
*/
|
|
protected function serializeFields(array $fields, string $prefix = '', string $parent = ''): array
|
|
{
|
|
$result = [];
|
|
|
|
foreach ($fields as $name => $field) {
|
|
if (!is_array($field)) {
|
|
continue;
|
|
}
|
|
|
|
$type = $field['type'] ?? null;
|
|
|
|
// Leading-dot relative naming. A child keyed `.optionA` binds under
|
|
// its container's own name rather than the (transparent) layout
|
|
// prefix, so `.optionA` inside a section named `header.sectionName`
|
|
// resolves to `header.sectionName.optionA` and saves nested. This
|
|
// mirrors core's BlueprintSchema::getFieldKey(); without it the bare
|
|
// `.optionA` reached the SPA and its values never saved.
|
|
if (is_string($name) && isset($name[0]) && $name[0] === '.') {
|
|
$base = $parent !== '' ? $parent : rtrim($prefix, '.');
|
|
$fieldPath = $base !== '' ? $base . $name : substr($name, 1);
|
|
} else {
|
|
$fieldPath = $prefix !== '' ? "{$prefix}.{$name}" : (string) $name;
|
|
}
|
|
|
|
// `users` field type: a reusable, permission-filtered user picker.
|
|
// Resolve its dropdown options from the field's own `access:` /
|
|
// `groups:` config so any blueprint can drop one in without extra
|
|
// server code. Stuffing the options back onto $field lets the
|
|
// normal options pipeline (translate + assoc→array) handle them.
|
|
if ($type === 'users') {
|
|
$field['options'] = $this->resolveUserFieldOptions($field);
|
|
}
|
|
|
|
$serialized = [
|
|
'name' => $fieldPath,
|
|
'type' => $type ?? 'text',
|
|
];
|
|
|
|
// Copy standard properties
|
|
$props = [
|
|
'label', 'help', 'placeholder', 'default', 'description', 'content',
|
|
'size', 'classes', 'id', 'style', 'title', 'text',
|
|
'disabled', 'readonly', 'toggleable', 'highlight',
|
|
'minlength', 'maxlength', 'min', 'max', 'step',
|
|
'rows', 'cols', 'multiple', 'yaml',
|
|
'markdown', 'prepend', 'append', 'underline',
|
|
'options', 'selectize', 'value_only', 'create',
|
|
'destination', 'accept', 'random_name', 'avoid_overwriting', 'filesize', 'limit',
|
|
'use', 'key', 'controls', 'collapsed',
|
|
'show_all', 'show_modular', 'show_root', 'show_slug',
|
|
'placeholder_key', 'placeholder_value', 'value_type',
|
|
'btnLabel', 'placement', 'sortby', 'sortby_dir',
|
|
'sort', 'collapsible', 'min_height', 'selectunique',
|
|
'condition', 'wrapper_classes',
|
|
'provider', 'translate',
|
|
'page_field', 'page_template', 'success_msg', 'error_msg',
|
|
// pagemediaselect / filepicker
|
|
'preview_images', 'preview_image', 'on_demand', 'folder', 'filter',
|
|
'self', 'display', 'resize', 'media_picker_field',
|
|
// colorpicker — opt out of the alpha slider with `alpha: false`.
|
|
'alpha',
|
|
];
|
|
|
|
foreach ($props as $prop) {
|
|
if (isset($field[$prop])) {
|
|
$serialized[$prop] = $field[$prop];
|
|
}
|
|
}
|
|
|
|
// Translate string properties that may contain language keys
|
|
foreach (['label', 'title', 'description', 'help', 'placeholder', 'text', 'content', 'success_msg', 'error_msg'] as $textProp) {
|
|
if (isset($serialized[$textProp]) && is_string($serialized[$textProp])) {
|
|
$serialized[$textProp] = $this->translateLabel($serialized[$textProp]);
|
|
}
|
|
}
|
|
|
|
// Translate option labels
|
|
if (isset($serialized['options']) && is_array($serialized['options'])) {
|
|
foreach ($serialized['options'] as $optKey => $optLabel) {
|
|
if (is_string($optLabel)) {
|
|
$serialized['options'][$optKey] = $this->translateLabel($optLabel);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Resolve data-options@ directives (dynamic options from PHP callables).
|
|
// Grav core's Blueprint::dynamicData() may have already populated
|
|
// $serialized['options'] using a stateless call; we replace it with
|
|
// our resolution because we have page-type context for pageTypes.
|
|
if (isset($field['data-options@'])) {
|
|
$directive = $field['data-options@'];
|
|
$resolved = $this->resolveDataDirective($directive);
|
|
if ($resolved !== null && count($resolved) > 0) {
|
|
$serialized['options'] = $resolved;
|
|
} else {
|
|
// Include the directive reference so client can resolve via /data/resolve
|
|
$serialized['data_options'] = is_string($directive) ? $directive : ($directive[0] ?? null);
|
|
}
|
|
}
|
|
|
|
// Convert options from {key: label} object to [{value, label}] array
|
|
// to preserve insertion order (JS re-sorts numeric object keys)
|
|
if (isset($serialized['options']) && is_array($serialized['options'])) {
|
|
$ordered = [];
|
|
foreach ($serialized['options'] as $optKey => $optLabel) {
|
|
$ordered[] = [
|
|
'value' => $this->normalizeOptionScalar($optKey),
|
|
'label' => $this->normalizeOptionScalar($optLabel),
|
|
];
|
|
}
|
|
$serialized['options'] = $ordered;
|
|
}
|
|
|
|
// Validation rules
|
|
if (isset($field['validate']) && is_array($field['validate'])) {
|
|
$serialized['validate'] = $field['validate'];
|
|
}
|
|
|
|
// Handle nested fields (structural containers)
|
|
if (isset($field['fields']) && is_array($field['fields'])) {
|
|
// For layout containers, don't add prefix (fields bind to their own names)
|
|
$layoutTypes = ['tabs', 'tab', 'section', 'fieldset', 'columns', 'column', 'page-exists', 'elements', 'element'];
|
|
$childPrefix = in_array($type, $layoutTypes, true) ? $prefix : $fieldPath;
|
|
|
|
// Always pass this field's resolved name as the parent so any
|
|
// leading-dot children bind under it, even when the container is
|
|
// a transparent layout type that leaves $childPrefix untouched.
|
|
$serialized['fields'] = $this->serializeFields($field['fields'], $childPrefix, $fieldPath);
|
|
}
|
|
|
|
$result[] = $serialized;
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Stringify an option key or label for the client.
|
|
*
|
|
* With strict YAML (system.strict_mode.yaml_compat: false) Grav parses
|
|
* blueprints with the native YAML 1.1 parser, which reads unquoted
|
|
* Yes/No/On/Off/y/n option labels as booleans. Left as booleans they
|
|
* render as a blank button or a literal "true"; mapping them back to
|
|
* Yes/No keeps these (Grav 1.7-era) blueprints working without asking
|
|
* authors — or end users — to quote every label. Option keys are never
|
|
* booleans (PHP casts bool array keys to 1/0), so they just stringify.
|
|
*/
|
|
private function normalizeOptionScalar(mixed $value): string
|
|
{
|
|
if (is_bool($value)) {
|
|
return $value ? 'Yes' : 'No';
|
|
}
|
|
|
|
return (string) $value;
|
|
}
|
|
}
|