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