Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.55% covered (success)
96.55%
56 / 58
80.00% covered (warning)
80.00%
8 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Resolver
96.55% covered (success)
96.55%
56 / 58
80.00% covered (warning)
80.00%
8 / 10
40
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 descriptor
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
8
 writableField
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 resolve
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 stored
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 provider
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 fieldMap
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
6.03
 dynamicMap
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 text
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 isTextField
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Title;
6
7use Cosray\Contract\Title as TitleContract;
8use Cosray\Field\Definitions;
9use Cosray\Field\Field;
10use Cosray\Field\Text;
11use Cosray\Locale;
12use Cosray\Locales;
13use Cosray\Node\Factory;
14use Cosray\Node\Types;
15
16/**
17 * Resolves runtime and materialized titles from one shared descriptor.
18 *
19 * @phpstan-type TitleDescriptor = array{kind: 'field', field: string}
20 *     |array{kind: 'dynamic', embedded?: string}
21 *     |array{kind: 'none'}
22 */
23class Resolver
24{
25    public const string KIND_DYNAMIC = 'dynamic';
26    public const string KIND_FIELD = 'field';
27    public const string KIND_NONE = 'none';
28
29    public function __construct(
30        private readonly Types $types,
31    ) {}
32
33    /**
34     * @param class-string $class
35     * @return TitleDescriptor
36     */
37    public function descriptor(string $class): array
38    {
39        if (is_a($class, TitleContract::class, true)) {
40            return ['kind' => self::KIND_DYNAMIC];
41        }
42
43        $titleEmbedded = $this->types->get($class, 'titleEmbedded');
44
45        if (is_string($titleEmbedded) && $titleEmbedded !== '') {
46            return ['kind' => self::KIND_DYNAMIC, 'embedded' => $titleEmbedded];
47        }
48
49        $titleField = $this->types->get($class, 'titleField');
50
51        if (is_string($titleField) && $titleField !== '' && $this->isTextField($class, $titleField)) {
52            return ['kind' => self::KIND_FIELD, 'field' => $titleField];
53        }
54
55        if ($this->isTextField($class, 'title')) {
56            return ['kind' => self::KIND_FIELD, 'field' => 'title'];
57        }
58
59        return ['kind' => self::KIND_NONE];
60    }
61
62    /**
63     * The writable Text field behind the title: the descriptor's field for
64     * a field-kind title, or the conventional `title` field for a dynamic
65     * one â€” the common `implements Title` idiom computes over exactly that
66     * field. Null when neither exists.
67     *
68     * @param class-string $class
69     */
70    public function writableField(string $class): ?string
71    {
72        $descriptor = $this->descriptor($class);
73
74        if ($descriptor['kind'] === self::KIND_FIELD) {
75            return $descriptor['field'];
76        }
77
78        return $this->isTextField($class, 'title') ? 'title' : null;
79    }
80
81    public function resolve(object $node): string
82    {
83        $descriptor = $this->descriptor($node::class);
84
85        if ($descriptor['kind'] === self::KIND_FIELD) {
86            $field = Factory::fieldFor($node, $descriptor['field']);
87
88            return $field instanceof Text ? $field->value()->raw() : '';
89        }
90
91        return $this->provider($node, $descriptor)?->title() ?? '';
92    }
93
94    /**
95     * Pick the title for a locale out of a materialized title map, walking the
96     * locale fallback chain and then the neutral key. Mirrors how a translated
97     * field value resolves in {@see \Cosray\Value\Value::effective()}.
98     *
99     * Null when the map holds nothing usable for that chain, which is the
100     * caller's signal to fall back to live resolution.
101     *
102     * @param array<string, mixed> $map
103     */
104    public function stored(array $map, ?Locale $locale): ?string
105    {
106        foreach ($locale === null ? [] : [$locale->id, ...$locale->fallbacks()] as $id) {
107            $title = $this->text($map, $id);
108
109            if ($title !== null) {
110                return $title;
111            }
112        }
113
114        return $this->text($map, Field::NEUTRAL_LOCALE);
115    }
116
117    /**
118     * @param ?TitleDescriptor $descriptor
119     */
120    public function provider(object $node, ?array $descriptor = null): ?TitleContract
121    {
122        $descriptor ??= $this->descriptor($node::class);
123
124        if ($descriptor['kind'] !== self::KIND_DYNAMIC) {
125            return null;
126        }
127
128        if (isset($descriptor['embedded'])) {
129            $embedded = Factory::embeddedFor($node, $descriptor['embedded']);
130
131            return $embedded instanceof TitleContract ? $embedded : null;
132        }
133
134        return $node instanceof TitleContract ? $node : null;
135    }
136
137    /**
138     * Extract the localized title map from stored content for a field-based title.
139     *
140     * @param array<string, mixed> $content
141     * @return array<string, string>
142     */
143    public function fieldMap(array $content, string $field): array
144    {
145        $value = $content[$field]['value'] ?? null;
146
147        if (!is_array($value)) {
148            return [];
149        }
150
151        $map = [];
152
153        foreach ($value as $locale => $text) {
154            if (!is_string($locale) || !is_string($text)) {
155                continue;
156            }
157
158            $text = trim($text);
159
160            if ($text !== '') {
161                $map[$locale] = $text;
162            }
163        }
164
165        return $map;
166    }
167
168    /**
169     * @param callable(Locale): string $titleFor
170     * @return array<string, string>
171     */
172    public function dynamicMap(callable $titleFor, Locales $locales): array
173    {
174        $map = [];
175        $count = 0;
176
177        foreach ($locales as $locale) {
178            $count++;
179            $text = trim($titleFor($locale));
180
181            if ($text !== '') {
182                $map[$locale->id] = $text;
183            }
184        }
185
186        if (count($map) === $count && count(array_unique($map)) === 1) {
187            return [Field::NEUTRAL_LOCALE => (string) reset($map)];
188        }
189
190        return $map;
191    }
192
193    /** @param array<string, mixed> $map */
194    private function text(array $map, string $key): ?string
195    {
196        $title = $map[$key] ?? null;
197
198        return is_string($title) && trim($title) !== '' ? $title : null;
199    }
200
201    /** @param class-string $class */
202    private function isTextField(string $class, string $property): bool
203    {
204        $definition = Definitions::for($class)->field($property);
205
206        return $definition !== null && is_a($definition->type, Text::class, true);
207    }
208}