Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
80 / 80
100.00% covered (success)
100.00%
26 / 26
CRAP
100.00% covered (success)
100.00%
1 / 1
NavigationDataFactory
100.00% covered (success)
100.00%
80 / 80
100.00% covered (success)
100.00%
26 / 26
40
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 toArray
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 makeLabel
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 makeGroup
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 makeHidden
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 makePriority
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 searchForLabelInFrontMatter
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 searchForGroupInFrontMatter
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 searchForHiddenInFrontMatter
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 isPageHiddenInNavigationConfiguration
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isNonDocumentationPageInHiddenSubdirectory
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 searchForPriorityInFrontMatter
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 searchForLabelInConfig
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 searchForPriorityInConfigs
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 searchForPriorityInSidebarConfig
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 searchForPriorityInNavigationConfig
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 parseNavigationPriorityConfig
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 canUseSubdirectoryForGroups
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 defaultGroup
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 pageIsInSubdirectory
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSubdirectoryName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSubdirectoryConfiguration
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isInstanceOf
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 invert
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 offset
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 getMatter
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Hyde\Framework\Factories;
6
7use Hyde\Facades\Config;
8use Illuminate\Support\Str;
9use Hyde\Pages\MarkdownPost;
10use Hyde\Pages\DocumentationPage;
11use Hyde\Markdown\Models\FrontMatter;
12use Hyde\Framework\Factories\Concerns\CoreDataObject;
13use Hyde\Markdown\Contracts\FrontMatter\SubSchemas\NavigationSchema;
14
15use function basename;
16use function array_flip;
17use function in_array;
18use function is_a;
19use function array_key_exists;
20
21/**
22 * Discover data used for navigation menus and the documentation sidebar.
23 */
24class NavigationDataFactory extends Concerns\PageDataFactory implements NavigationSchema
25{
26    /**
27     * The front matter properties supported by this factory.
28     *
29     * Note that this represents a sub-schema, and is used as part of the page schema.
30     */
31    final public const SCHEMA = NavigationSchema::NAVIGATION_SCHEMA;
32
33    protected const FALLBACK_PRIORITY = 999;
34    protected const CONFIG_OFFSET = 500;
35
36    protected readonly ?string $label;
37    protected readonly ?string $group;
38    protected readonly ?bool $hidden;
39    protected readonly ?int $priority;
40    private readonly string $title;
41    private readonly string $routeKey;
42    private readonly string $pageClass;
43    private readonly string $identifier;
44    private readonly FrontMatter $matter;
45
46    public function __construct(CoreDataObject $pageData, string $title)
47    {
48        $this->matter = $pageData->matter;
49        $this->identifier = $pageData->identifier;
50        $this->pageClass = $pageData->pageClass;
51        $this->routeKey = $pageData->routeKey;
52        $this->title = $title;
53
54        $this->label = $this->makeLabel();
55        $this->group = $this->makeGroup();
56        $this->hidden = $this->makeHidden();
57        $this->priority = $this->makePriority();
58    }
59
60    /**
61     * @return array{label: string|null, group: string|null, hidden: bool|null, priority: int|null}
62     */
63    public function toArray(): array
64    {
65        return [
66            'label' => $this->label,
67            'group' => $this->group,
68            'hidden' => $this->hidden,
69            'priority' => $this->priority,
70        ];
71    }
72
73    protected function makeLabel(): ?string
74    {
75        return $this->searchForLabelInFrontMatter()
76            ?? $this->searchForLabelInConfig()
77            ?? $this->getMatter('title')
78            ?? $this->title;
79    }
80
81    protected function makeGroup(): ?string
82    {
83        if ($this->pageIsInSubdirectory() && $this->canUseSubdirectoryForGroups()) {
84            return $this->getSubdirectoryName();
85        }
86
87        return $this->searchForGroupInFrontMatter() ?? $this->defaultGroup();
88    }
89
90    protected function makeHidden(): bool
91    {
92        return $this->isInstanceOf(MarkdownPost::class)
93            || $this->searchForHiddenInFrontMatter()
94            || $this->isPageHiddenInNavigationConfiguration()
95            || $this->isNonDocumentationPageInHiddenSubdirectory();
96    }
97
98    protected function makePriority(): int
99    {
100        return $this->searchForPriorityInFrontMatter()
101            ?? $this->searchForPriorityInConfigs()
102            ?? self::FALLBACK_PRIORITY;
103    }
104
105    private function searchForLabelInFrontMatter(): ?string
106    {
107        return $this->getMatter('navigation.label')
108            ?? $this->getMatter('navigation.title');
109    }
110
111    private function searchForGroupInFrontMatter(): ?string
112    {
113        return $this->getMatter('navigation.group')
114            ?? $this->getMatter('navigation.category');
115    }
116
117    private function searchForHiddenInFrontMatter(): ?bool
118    {
119        return $this->getMatter('navigation.hidden')
120            ?? $this->invert($this->getMatter('navigation.visible'));
121    }
122
123    private function isPageHiddenInNavigationConfiguration(): bool
124    {
125        return in_array($this->routeKey, Config::getArray('hyde.navigation.exclude', ['404']));
126    }
127
128    private function isNonDocumentationPageInHiddenSubdirectory(): bool
129    {
130        return ! $this->isInstanceOf(DocumentationPage::class)
131            && $this->pageIsInSubdirectory()
132            && $this->getSubdirectoryConfiguration() === 'hidden'
133            && basename($this->identifier) !== 'index';
134    }
135
136    private function searchForPriorityInFrontMatter(): ?int
137    {
138        return $this->getMatter('navigation.priority')
139            ?? $this->getMatter('navigation.order');
140    }
141
142    private function searchForLabelInConfig(): ?string
143    {
144        /** @var array<string, string> $config */
145        $config = Config::getArray('hyde.navigation.labels', [
146            'index' => 'Home',
147            DocumentationPage::homeRouteName() => 'Docs',
148        ]);
149
150        return $config[$this->routeKey] ?? null;
151    }
152
153    private function searchForPriorityInConfigs(): ?int
154    {
155        return $this->isInstanceOf(DocumentationPage::class)
156            ? $this->searchForPriorityInSidebarConfig()
157            : $this->searchForPriorityInNavigationConfig();
158    }
159
160    private function searchForPriorityInSidebarConfig(): ?int
161    {
162        /** @var array<string>|array<string, int> $config */
163        $config = Config::getArray('docs.sidebar_order', []);
164
165        return
166            // For consistency with the navigation config.
167            $this->parseNavigationPriorityConfig($config, 'routeKey')
168            // For backwards compatibility, and ease of use, as the route key prefix
169            // is redundant due to it being the same for all documentation pages
170            ?? $this->parseNavigationPriorityConfig($config, 'identifier');
171    }
172
173    private function searchForPriorityInNavigationConfig(): ?int
174    {
175        /** @var array<string, int>|array<string> $config */
176        $config = Config::getArray('hyde.navigation.order', [
177            'index' => 0,
178            'posts' => 10,
179            'docs/index' => 100,
180        ]);
181
182        return $this->parseNavigationPriorityConfig($config, 'routeKey');
183    }
184
185    /**
186     * @param  array<string, int>|array<string>  $config
187     * @param  'routeKey'|'identifier'  $pageKeyName
188     */
189    private function parseNavigationPriorityConfig(array $config, string $pageKeyName): ?int
190    {
191        /** @var string $pageKey */
192        $pageKey = $this->{$pageKeyName};
193
194        // Check if the config entry is a flat array or a keyed array.
195        if (! array_key_exists($pageKey, $config)) {
196            // Adding an offset makes so that pages with a front matter priority, or
197            // explicit keyed priority selection that is lower can be shown first.
198            // This is all to make it easier to mix ways of adding priorities.
199
200            return $this->offset(
201                array_flip($config)[$pageKey] ?? null,
202                self::CONFIG_OFFSET
203            );
204        }
205
206        return $config[$pageKey] ?? null;
207    }
208
209    private function canUseSubdirectoryForGroups(): bool
210    {
211        return $this->getSubdirectoryConfiguration() === 'dropdown'
212            || $this->isInstanceOf(DocumentationPage::class);
213    }
214
215    private function defaultGroup(): ?string
216    {
217        return $this->isInstanceOf(DocumentationPage::class) ? 'other' : null;
218    }
219
220    private function pageIsInSubdirectory(): bool
221    {
222        return Str::contains($this->identifier, '/');
223    }
224
225    private function getSubdirectoryName(): string
226    {
227        return Str::before($this->identifier, '/');
228    }
229
230    protected function getSubdirectoryConfiguration(): string
231    {
232        return Config::getString('hyde.navigation.subdirectories', 'hidden');
233    }
234
235    /** @param class-string<\Hyde\Pages\Concerns\HydePage> $class */
236    protected function isInstanceOf(string $class): bool
237    {
238        return is_a($this->pageClass, $class, true);
239    }
240
241    protected function invert(?bool $value): ?bool
242    {
243        return $value === null ? null : ! $value;
244    }
245
246    protected function offset(?int $value, int $offset): ?int
247    {
248        return $value === null ? null : $value + $offset;
249    }
250
251    protected function getMatter(string $key): string|null|int|bool
252    {
253        /** @var string|null|int|bool $value */
254        $value = $this->matter->get($key);
255
256        return $value;
257    }
258}