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