Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
51 / 51
100.00% covered (success)
100.00%
9 / 9
CRAP
100.00% covered (success)
100.00%
1 / 1
SchemaScanner
100.00% covered (success)
100.00%
51 / 51
100.00% covered (success)
100.00%
9 / 9
32
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
 fromApp
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 scan
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 warnings
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fromClass
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 fromProperties
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
10
 optionLabels
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 add
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 walk
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\I18n;
6
7use Celema\Verba\Tool\Message;
8use Celema\Verba\Tool\Scanner;
9use Cosray\App;
10use Cosray\Collection\AllContent;
11use Cosray\Collection\Ref;
12use Cosray\Field\Definitions;
13use Cosray\NavigationItem;
14use Cosray\Schema\Badge;
15use Cosray\Schema\Description;
16use Cosray\Schema\Label;
17use Cosray\Schema\Options;
18use Cosray\Schema\Placeholder;
19use Cosray\Schema\StateLabels;
20use Override;
21use ReflectionClass;
22
23/**
24 * Harvests the translatable strings Cosray reads off schemas at serialization
25 * time so `i18n:sync` sees them like any `__()` call: node and collection
26 * labels/badges, field labels, descriptions and option labels, and the
27 * navigation section, link and collection names.
28 *
29 * The strings are read straight from the schema attributes by reflection and
30 * from the booted navigation tree — nothing is instantiated. It mirrors the
31 * runtime translation points in Node\Serializer, Field\Field and Navigation, so
32 * the ids it emits are exactly the ones looked up when the panel renders.
33 *
34 * @api
35 */
36final class SchemaScanner implements Scanner
37{
38    /**
39     * @param list<class-string> $classes node types to reflect for schema attributes
40     * @param list<string> $labels ready display strings (navigation names)
41     */
42    public function __construct(
43        private readonly array $classes,
44        private readonly array $labels = [],
45    ) {}
46
47    /**
48     * Build a scanner from a booted app: every registered node type plus the
49     * navigation section, link and collection names. Cosray's built-in
50     * collection is left out: its label is Cosray's message, which Cosray's
51     * own catalogs translate, so the app's domain lists only what the app
52     * declares.
53     */
54    public static function fromApp(App $app): self
55    {
56        $labels = [];
57        self::walk($app->navigation()->items(), $labels);
58
59        return new self($app->bootstrap()->nodeClasses(), array_values(array_unique($labels)));
60    }
61
62    /**
63     * @return list<Message>
64     */
65    #[Override]
66    public function scan(): array
67    {
68        /** @var array<string, Message> $messages */
69        $messages = [];
70
71        foreach ($this->labels as $label) {
72            $this->add($messages, $label, 'navigation');
73        }
74
75        foreach ($this->classes as $class) {
76            $this->fromClass($class, $messages);
77        }
78
79        return array_values($messages);
80    }
81
82    /**
83     * @return list<string>
84     */
85    #[Override]
86    public function warnings(): array
87    {
88        return [];
89    }
90
91    /**
92     * @param class-string $class
93     * @param array<string, Message> $messages
94     */
95    private function fromClass(string $class, array &$messages): void
96    {
97        $reflection = new ReflectionClass($class);
98
99        foreach ($reflection->getAttributes(Label::class) as $attribute) {
100            $this->add($messages, $attribute->newInstance()->label, $class);
101        }
102
103        foreach ($reflection->getAttributes(Badge::class) as $attribute) {
104            $this->add($messages, $attribute->newInstance()->badge, $class);
105        }
106
107        $this->fromProperties($reflection, $messages);
108
109        foreach (Definitions::for($class)->embedded() as $embedded) {
110            $embeddedReflection = new ReflectionClass($embedded->type);
111
112            foreach ($embeddedReflection->getAttributes(Label::class) as $attribute) {
113                $this->add($messages, $attribute->newInstance()->label, $embedded->type);
114            }
115
116            $this->fromProperties($embeddedReflection, $messages);
117        }
118    }
119
120    /**
121     * @param ReflectionClass<object> $reflection
122     * @param array<string, Message> $messages
123     */
124    private function fromProperties(ReflectionClass $reflection, array &$messages): void
125    {
126        foreach ($reflection->getProperties() as $property) {
127            $where = $reflection->getName() . '::$' . $property->getName();
128
129            foreach ($property->getAttributes(Label::class) as $attribute) {
130                $this->add($messages, $attribute->newInstance()->label, $where);
131            }
132
133            foreach ($property->getAttributes(Description::class) as $attribute) {
134                $this->add($messages, $attribute->newInstance()->description, $where);
135            }
136
137            foreach ($property->getAttributes(Placeholder::class) as $attribute) {
138                $this->add($messages, $attribute->newInstance()->placeholder, $where);
139            }
140
141            foreach ($property->getAttributes(StateLabels::class) as $attribute) {
142                foreach (get_object_vars($attribute->newInstance()) as $label) {
143                    if ($label !== null) {
144                        $this->add($messages, $label, $where);
145                    }
146                }
147            }
148
149            foreach ($property->getAttributes(Options::class) as $attribute) {
150                foreach ($this->optionLabels($attribute->newInstance()->options) as $label) {
151                    $this->add($messages, $label, $where);
152                }
153            }
154        }
155    }
156
157    /**
158     * Select options mirror Field::localizeOption: only array options carry a
159     * translatable `label`; a plain-string option doubles as its own value.
160     *
161     * @param array<array-key, mixed> $options
162     * @return list<string>
163     */
164    private function optionLabels(array $options): array
165    {
166        $labels = [];
167
168        foreach ($options as $option) {
169            if (!is_array($option) || !is_string($option['label'] ?? null)) {
170                continue;
171            }
172
173            $labels[] = $option['label'];
174        }
175
176        return $labels;
177    }
178
179    /**
180     * @param array<string, Message> $messages
181     */
182    private function add(array &$messages, string $id, string $location): void
183    {
184        if ($id === '' || isset($messages[$id])) {
185            return;
186        }
187
188        $messages[$id] = new Message(null, $id, null, [$location]);
189    }
190
191    /**
192     * @param list<NavigationItem> $items
193     * @param list<string> $labels
194     */
195    private static function walk(array $items, array &$labels): void
196    {
197        foreach ($items as $item) {
198            if ($item instanceof Ref && $item->class === AllContent::class) {
199                continue;
200            }
201
202            $labels[] = $item->meta->label;
203            self::walk($item->children(), $labels);
204        }
205    }
206}