Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
45 / 45
100.00% covered (success)
100.00%
4 / 4
CRAP
100.00% covered (success)
100.00%
1 / 1
PositionManager
100.00% covered (success)
100.00%
44 / 44
100.00% covered (success)
100.00%
4 / 4
18
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 renderPosition
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 resolveBlocks
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
7
 renderBlock
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\Layout;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Api\ApiClientInterface;
12use App\Core\Layout\Presentation\Api\LayoutApiController;
13use PDO;
14use Psr\Container\ContainerInterface;
15use Throwable;
16
17/**
18 * Layout Position Manager.
19 *
20 * Resolves and renders dynamic UI block components for layout positions via REST API / SQL.
21 *
22 * @package App\Core\Layout
23 */
24final class PositionManager
25{
26    /** @var array<string, array<int, string>> In-memory cache for resolved block class lists. */
27    private array $blocksCache = [];
28
29    /**
30     * PositionManager constructor.
31     *
32     * @param LayoutApiController|ApiClientInterface|PDO|null $source API controller, client or database connection.
33     * @param ContainerInterface|null $container PSR-11 Dependency injection container.
34     * @param string $tablePrefix Database table prefix.
35     */
36    public function __construct(
37        private readonly LayoutApiController|ApiClientInterface|PDO|null $source = null,
38        private readonly ?ContainerInterface $container = null,
39        private readonly string $tablePrefix = 'a_'
40    ) {
41    }
42
43    /**
44     * Renders all active blocks assigned to given position and theme.
45     *
46     * @param string $positionCode Layout position code (e.g. 'sidebar-left', 'header-top').
47     * @param string $themeName Active theme name.
48     * @param array<string, mixed> $options Rendering parameters.
49     * @return string Concatenated HTML string of rendered blocks.
50     */
51    public function renderPosition(
52        string $positionCode,
53        string $themeName = 'authenticated',
54        array $options = []
55    ): string {
56        $blockClasses = $this->resolveBlocks($positionCode, $themeName);
57        if (empty($blockClasses)) {
58            return '';
59        }
60
61        $html = '';
62        foreach ($blockClasses as $blockClass) {
63            $rendered = $this->renderBlock($blockClass, $options);
64            if ($rendered !== null && $rendered !== '') {
65                $html .= $rendered;
66            }
67        }
68
69        return $html;
70    }
71
72    /**
73     * Resolves active block class names assigned to position.
74     *
75     * @param string $positionCode Position code.
76     * @param string $themeName Theme identifier.
77     * @return array<int, string> Array of fully-qualified Block class names.
78     */
79    private function resolveBlocks(string $positionCode, string $themeName): array
80    {
81        $cacheKey = "{$positionCode}:{$themeName}";
82        if (isset($this->blocksCache[$cacheKey])) {
83            return $this->blocksCache[$cacheKey];
84        }
85
86        if ($this->source === null) {
87            return [];
88        }
89
90        try {
91            if ($this->source instanceof LayoutApiController) {
92                $blockClasses = $this->source->fetchPositionBlocksFromDb($positionCode, $themeName);
93            } elseif ($this->source instanceof ApiClientInterface) {
94                $res = $this->source->get('/api/v1/layout/positions/' . $positionCode, ['theme' => $themeName]);
95                /** @var array<int, string> $blockClasses */
96                $blockClasses = (array)($res['blocks'] ?? []);
97            } else {
98                $posTable = $this->tablePrefix . 'core_layout_records';
99                $assignTable = $this->tablePrefix . 'core_layout_assignments';
100
101                $sql = sprintf(
102                    'SELECT a.`block_class` FROM `%s` a ' .
103                    'INNER JOIN `%s` p ON a.`position_id` = p.`id` ' .
104                    'WHERE p.`position_code` = :pos AND p.`theme_name` = :theme AND a.`is_active` = 1 ' .
105                    'ORDER BY a.`sort_order` ASC',
106                    $assignTable,
107                    $posTable
108                );
109
110                /** @var PDO $pdo */
111                $pdo = $this->source;
112                $stmt = $pdo->prepare($sql);
113                $stmt->execute([':pos' => $positionCode, ':theme' => $themeName]);
114                /** @var array<int, string> $blockClasses */
115                $blockClasses = $stmt->fetchAll(PDO::FETCH_COLUMN) ?: [];
116            }
117        } catch (Throwable) {
118            $blockClasses = [];
119        }
120
121        $this->blocksCache[$cacheKey] = $blockClasses;
122
123        return $blockClasses;
124    }
125
126    /**
127     * Instantiates and renders a single block class instance.
128     *
129     * @param string $blockClass Fully-qualified block class name.
130     * @param array<string, mixed> $options Rendering parameters.
131     * @return string|null Rendered HTML string or null if failed.
132     */
133    private function renderBlock(string $blockClass, array $options): ?string
134    {
135        if (!class_exists($blockClass) || $this->container === null) {
136            return null;
137        }
138
139        try {
140            /** @var mixed $block */
141            $block = $this->container->get($blockClass);
142            return $block instanceof BlockInterface ? $block->render($options) : null;
143        } catch (Throwable) {
144            return null;
145        }
146    }
147}