Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.44% covered (success)
97.44%
76 / 78
88.24% covered (warning)
88.24%
15 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
Context
97.44% covered (success)
97.44%
76 / 78
88.24% covered (warning)
88.24%
15 / 17
31
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
 console
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 create
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 assets
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 paths
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 titleSort
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 locales
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 locale
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 defaultLocale
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 localeId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 translator
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 httpRequest
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 origin
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
8
 withLocale
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 currentRequest
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 absentRequest
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 originOf
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray;
6
7use Celema\Container\Container;
8use Celema\Core\Factory\Factory;
9use Celema\Core\Request;
10use Celema\Quma\Database;
11use Celema\Verba\Translator;
12use Celema\Verba\Verba;
13use Celema\Wire\Creator;
14use Closure;
15use ReflectionClass;
16
17final class Context
18{
19    private ?Assets\Repository $assets = null;
20    private ?Node\UrlPaths $paths = null;
21    private ?Title\Sort $titleSort = null;
22    private ?Locales $runtimeLocales = null;
23    private ?Locale $runtimeLocale = null;
24
25    /** The request carrying the locale withLocale() switched to, while its callback runs. */
26    private ?Request $localeRequest = null;
27
28    /** @var array<string, Translator> */
29    private array $translators = [];
30
31    public function __construct(
32        public readonly Database $db,
33        public readonly ?Request $request,
34        public readonly Config $config,
35        public readonly Container $container,
36        public readonly Factory $factory,
37    ) {}
38
39    public static function console(
40        Database $db,
41        Config $config,
42        Container $container,
43        Factory $factory,
44        Locales $locales,
45    ): self {
46        $context = new self($db, null, $config, $container, $factory);
47        $context->runtimeLocales = $locales;
48        $context->runtimeLocale = $locales->getDefault();
49
50        return $context;
51    }
52
53    /**
54     * Builds a new object for this request. Its constructor can ask for
55     * this Context, the Request, Config, Database, the core Factory and the
56     * request's container, plus the given types; other arguments are
57     * autowired through the request's container. Nodes, embedded objects,
58     * collections, block types and dashboard cards are built this way.
59     * Without a request, as in commands, jobs and the node writer, the
60     * Request is a stand-in that throws once it is used.
61     *
62     * @template T of object
63     * @param class-string<T> $class
64     * @param array<string, object> $types
65     * @return T
66     */
67    public function create(string $class, array $types = []): object
68    {
69        $base = [
70            self::class => $this,
71            Config::class => $this->config,
72            Database::class => $this->db,
73            Factory::class => $this->factory,
74            Container::class => $this->container,
75        ];
76
77        $base[Request::class] = $this->currentRequest() ?? self::absentRequest();
78        $instance = new Creator($this->container)->create($class, predefinedTypes: $types + $base);
79        assert($instance instanceof $class, 'The creator returns the requested class');
80
81        return $instance;
82    }
83
84    public function assets(): Assets\Repository
85    {
86        return $this->assets ??= new Assets\Repository($this->db, $this->config);
87    }
88
89    public function paths(): Node\UrlPaths
90    {
91        return $this->paths ??= new Node\UrlPaths($this->db);
92    }
93
94    public function titleSort(): Title\Sort
95    {
96        return $this->titleSort ??= new Title\Sort($this->db);
97    }
98
99    public function locales(): Locales
100    {
101        if ($this->runtimeLocales !== null) {
102            return $this->runtimeLocales;
103        }
104
105        $locales = $this->currentRequest()?->get('locales', null);
106
107        if (!$locales instanceof Locales) {
108            throw new Exception\RuntimeException('Locales are not available in this CMS context');
109        }
110
111        return $locales;
112    }
113
114    public function locale(): Locale
115    {
116        if ($this->runtimeLocale !== null) {
117            return $this->runtimeLocale;
118        }
119
120        $locale = $this->currentRequest()?->get('locale', null);
121
122        if (!$locale instanceof Locale) {
123            throw new Exception\RuntimeException('The current locale is not available in this CMS context');
124        }
125
126        return $locale;
127    }
128
129    public function defaultLocale(): Locale
130    {
131        return $this->locales()->getDefault();
132    }
133
134    public function localeId(): string
135    {
136        return $this->locale()->id;
137    }
138
139    public function translator(): Translator
140    {
141        $locale = $this->locale();
142
143        return $this->translators[$locale->id] ??= new Translator(
144            $locale->id,
145            $this->locales()->catalogs(),
146            $locale->fallbacks(),
147        );
148    }
149
150    public function httpRequest(): Request
151    {
152        return $this->currentRequest() ?? throw new Exception\RuntimeException(
153            'An HTTP request is not available in this CMS context',
154        );
155    }
156
157    /**
158     * The scheme and host absolute URLs start with: the request's, or else
159     * those of `app.url`. A locale with domains of its own gets its first
160     * one in place of a host that is not among them, as only they answer its
161     * paths, unless the request on that host was answered in the locale.
162     */
163    public function origin(): string
164    {
165        $origin = $this->request?->origin() ?: self::originOf($this->config->app->url);
166        $requested = $this->request?->get('locale', null);
167        $locale = $this->runtimeLocale ?? $requested;
168
169        if ($origin === '' || !$locale instanceof Locale || $locale->domains === []) {
170            return $origin;
171        }
172
173        $host = strtolower((string) parse_url($origin, PHP_URL_HOST));
174        $answered = $requested instanceof Locale && $requested->id === $locale->id;
175
176        if ($answered || in_array($host, $locale->domains, true)) {
177            return $origin;
178        }
179
180        return self::originOf($origin, $locale->domain());
181    }
182
183    /**
184     * Runs the callback in another locale. Code that reads the locale off
185     * the request, such as templates, gets a request carrying it.
186     */
187    public function withLocale(Locale $locale, Closure $callback): mixed
188    {
189        $runtimeLocale = $this->runtimeLocale;
190        $localeRequest = $this->localeRequest;
191        $translator = Verba::translator();
192        $this->runtimeLocale = $locale;
193        $this->localeRequest = $this->request?->with('locale', $locale);
194        Verba::activate($this->translator());
195
196        try {
197            return $callback();
198        } finally {
199            $this->runtimeLocale = $runtimeLocale;
200            $this->localeRequest = $localeRequest;
201
202            if ($translator !== null) {
203                Verba::activate($translator);
204            } else {
205                Verba::deactivate();
206            }
207        }
208    }
209
210    private function currentRequest(): ?Request
211    {
212        return $this->localeRequest ?? $this->request;
213    }
214
215    /** Lets a node whose form handler reads the request still be written and indexed. */
216    private static function absentRequest(): Request
217    {
218        return new ReflectionClass(Request::class)->newLazyProxy(static function (): never {
219            throw new Exception\RuntimeException(
220                'There is no HTTP request here: commands, jobs and the node writer run without one.',
221            );
222        });
223    }
224
225    /** The scheme, host and port of an absolute URL, with another host if one is given. */
226    private static function originOf(?string $url, ?string $host = null): string
227    {
228        if ($url === null) {
229            return '';
230        }
231
232        $port = parse_url($url, PHP_URL_PORT);
233
234        return (
235            (string) parse_url($url, PHP_URL_SCHEME)
236                . '://'
237                . ($host ?? (string) parse_url($url, PHP_URL_HOST))
238                . (is_int($port) ? ":{$port}" : '')
239        );
240    }
241}