vendor/contao/core-bundle/src/Image/Studio/FigureBuilder.php line 602

Open in your IDE?
  1. <?php
  2. declare(strict_types=1);
  3. /*
  4. * This file is part of Contao.
  5. *
  6. * (c) Leo Feyer
  7. *
  8. * @license LGPL-3.0-or-later
  9. */
  10. namespace Contao\CoreBundle\Image\Studio;
  11. use Contao\CoreBundle\Event\FileMetadataEvent;
  12. use Contao\CoreBundle\Exception\InvalidResourceException;
  13. use Contao\CoreBundle\File\Metadata;
  14. use Contao\CoreBundle\Framework\Adapter;
  15. use Contao\CoreBundle\Util\LocaleUtil;
  16. use Contao\FilesModel;
  17. use Contao\Image\ImageInterface;
  18. use Contao\Image\PictureConfiguration;
  19. use Contao\Image\ResizeOptions;
  20. use Contao\PageModel;
  21. use Contao\StringUtil;
  22. use Contao\Validator;
  23. use Nyholm\Psr7\Uri;
  24. use Psr\Container\ContainerInterface;
  25. use Symfony\Component\Filesystem\Filesystem;
  26. use Symfony\Component\Filesystem\Path;
  27. /**
  28. * Use the FigureBuilder class to create Figure result objects. The class
  29. * has a fluent interface to configure the desired output. When you are ready,
  30. * call build() to get a Figure. If you need another instance with similar
  31. * settings, you can alter values and call build() again - it will not affect
  32. * your first instance.
  33. */
  34. class FigureBuilder
  35. {
  36. private ContainerInterface $locator;
  37. private string $projectDir;
  38. private string $uploadPath;
  39. private string $webDir;
  40. private Filesystem $filesystem;
  41. private ?InvalidResourceException $lastException = null;
  42. /**
  43. * @var array<string>
  44. */
  45. private array $validExtensions;
  46. /**
  47. * The resource's absolute file path.
  48. */
  49. private ?string $filePath = null;
  50. /**
  51. * The resource's file model if applicable.
  52. */
  53. private ?FilesModel $filesModel = null;
  54. /**
  55. * User defined size configuration.
  56. *
  57. * @phpcsSuppress SlevomatCodingStandard.Classes.UnusedPrivateElements
  58. *
  59. * @var int|string|array|PictureConfiguration|null
  60. */
  61. private $sizeConfiguration;
  62. /**
  63. * User defined resize options.
  64. *
  65. * @phpcsSuppress SlevomatCodingStandard.Classes.UnusedPrivateElements
  66. */
  67. private ?ResizeOptions $resizeOptions = null;
  68. /**
  69. * User defined custom locale. This will overwrite the default if set.
  70. */
  71. private ?string $locale = null;
  72. /**
  73. * User defined metadata. This will overwrite the default if set.
  74. */
  75. private ?Metadata $metadata = null;
  76. /**
  77. * User defined metadata. This will be added to the default if set.
  78. */
  79. private ?Metadata $overwriteMetadata = null;
  80. /**
  81. * Determines if a metadata should never be present in the output.
  82. */
  83. private ?bool $disableMetadata = null;
  84. /**
  85. * User defined link attributes. These will add to or overwrite the default values.
  86. *
  87. * @var array<string, string|null>
  88. */
  89. private array $additionalLinkAttributes = [];
  90. /**
  91. * User defined lightbox resource or url. This will overwrite the default if set.
  92. *
  93. * @var string|ImageInterface|null
  94. */
  95. private $lightboxResourceOrUrl;
  96. /**
  97. * User defined lightbox size configuration. This will overwrite the default if set.
  98. *
  99. * @var int|string|array|PictureConfiguration|null
  100. */
  101. private $lightboxSizeConfiguration;
  102. /**
  103. * User defined lightbox resize options.
  104. */
  105. private ?ResizeOptions $lightboxResizeOptions = null;
  106. /**
  107. * User defined lightbox group identifier. This will overwrite the default if set.
  108. */
  109. private ?string $lightboxGroupIdentifier = null;
  110. /**
  111. * Determines if a lightbox (or "fullsize") image should be created.
  112. */
  113. private ?bool $enableLightbox = null;
  114. /**
  115. * User defined template options.
  116. *
  117. * @phpcsSuppress SlevomatCodingStandard.Classes.UnusedPrivateElements
  118. *
  119. * @var array<string, mixed>
  120. */
  121. private array $options = [];
  122. /**
  123. * @internal Use the Contao\CoreBundle\Image\Studio\Studio factory to get an instance of this class
  124. */
  125. public function __construct(ContainerInterface $locator, string $projectDir, string $uploadPath, string $webDir, array $validExtensions)
  126. {
  127. $this->locator = $locator;
  128. $this->projectDir = $projectDir;
  129. $this->uploadPath = $uploadPath;
  130. $this->webDir = $webDir;
  131. $this->validExtensions = $validExtensions;
  132. $this->filesystem = new Filesystem();
  133. }
  134. /**
  135. * Sets the image resource from a FilesModel.
  136. */
  137. public function fromFilesModel(FilesModel $filesModel): self
  138. {
  139. $this->lastException = null;
  140. if ('file' !== $filesModel->type) {
  141. $this->lastException = new InvalidResourceException(sprintf('DBAFS item "%s" is not a file.', $filesModel->path));
  142. return $this;
  143. }
  144. $this->filePath = Path::makeAbsolute($filesModel->path, $this->projectDir);
  145. $this->filesModel = $filesModel;
  146. if (!$this->filesystem->exists($this->filePath)) {
  147. $this->lastException = new InvalidResourceException(sprintf('No resource could be located at path "%s".', $this->filePath));
  148. }
  149. return $this;
  150. }
  151. /**
  152. * Sets the image resource from a tl_files UUID.
  153. */
  154. public function fromUuid(string $uuid): self
  155. {
  156. $this->lastException = null;
  157. $filesModel = $this->getFilesModelAdapter()->findByUuid($uuid);
  158. if (null === $filesModel) {
  159. $this->lastException = new InvalidResourceException(sprintf('DBAFS item with UUID "%s" could not be found.', $uuid));
  160. return $this;
  161. }
  162. return $this->fromFilesModel($filesModel);
  163. }
  164. /**
  165. * Sets the image resource from a tl_files ID.
  166. */
  167. public function fromId(int $id): self
  168. {
  169. $this->lastException = null;
  170. /** @var FilesModel|null $filesModel */
  171. $filesModel = $this->getFilesModelAdapter()->findByPk($id);
  172. if (null === $filesModel) {
  173. $this->lastException = new InvalidResourceException(sprintf('DBAFS item with ID "%s" could not be found.', $id));
  174. return $this;
  175. }
  176. return $this->fromFilesModel($filesModel);
  177. }
  178. /**
  179. * Sets the image resource from an absolute or relative path.
  180. *
  181. * @param bool $autoDetectDbafsPaths Set to false to skip searching for a FilesModel
  182. */
  183. public function fromPath(string $path, bool $autoDetectDbafsPaths = true): self
  184. {
  185. $this->lastException = null;
  186. // Make sure path is absolute and in a canonical form
  187. $path = Path::isAbsolute($path) ? Path::canonicalize($path) : Path::makeAbsolute($path, $this->projectDir);
  188. // Only check for a FilesModel if the resource is inside the upload path
  189. $getDbafsPath = function (string $path): ?string {
  190. if (Path::isBasePath(Path::join($this->webDir, $this->uploadPath), $path)) {
  191. return Path::makeRelative($path, $this->webDir);
  192. }
  193. if (Path::isBasePath(Path::join($this->projectDir, $this->uploadPath), $path)) {
  194. return $path;
  195. }
  196. return null;
  197. };
  198. if ($autoDetectDbafsPaths && null !== ($dbafsPath = $getDbafsPath($path))) {
  199. $filesModel = $this->getFilesModelAdapter()->findByPath($dbafsPath);
  200. if (null !== $filesModel) {
  201. return $this->fromFilesModel($filesModel);
  202. }
  203. }
  204. $this->filePath = $path;
  205. $this->filesModel = null;
  206. if (!$this->filesystem->exists($this->filePath)) {
  207. $this->lastException = new InvalidResourceException(sprintf('No resource could be located at path "%s".', $this->filePath));
  208. }
  209. return $this;
  210. }
  211. /**
  212. * Sets the image resource from an absolute or relative URL.
  213. *
  214. * @param list<string> $baseUrls a list of allowed base URLs, the first match gets stripped from the resource URL
  215. */
  216. public function fromUrl(string $url, array $baseUrls = []): self
  217. {
  218. $this->lastException = null;
  219. $uri = new Uri($url);
  220. $path = null;
  221. foreach ($baseUrls as $baseUrl) {
  222. $baseUri = new Uri($baseUrl);
  223. if ($baseUri->getHost() === $uri->getHost() && Path::isBasePath($baseUri->getPath(), $uri->getPath())) {
  224. $path = Path::makeRelative($uri->getPath(), $baseUri->getPath().'/');
  225. break;
  226. }
  227. }
  228. if (null === $path) {
  229. if ('' !== $uri->getHost()) {
  230. $this->lastException = new InvalidResourceException(sprintf('Resource URL "%s" outside of base URLs "%s".', $url, implode('", "', $baseUrls)));
  231. return $this;
  232. }
  233. $path = $uri->getPath();
  234. }
  235. if (preg_match('/%2f|%5c/i', $path)) {
  236. $this->lastException = new InvalidResourceException(sprintf('Resource URL path "%s" contains invalid percent encoding.', $path));
  237. return $this;
  238. }
  239. // Prepend the web_dir (see #6123)
  240. return $this->fromPath(Path::join($this->webDir, urldecode($path)));
  241. }
  242. /**
  243. * Sets the image resource from an ImageInterface.
  244. */
  245. public function fromImage(ImageInterface $image): self
  246. {
  247. return $this->fromPath($image->getPath());
  248. }
  249. /**
  250. * Sets the image resource by guessing the identifier type.
  251. *
  252. * @param int|string|FilesModel|ImageInterface|null $identifier Can be a FilesModel, an ImageInterface, a tl_files UUID/ID/path or a file system path
  253. */
  254. public function from($identifier): self
  255. {
  256. if (null === $identifier) {
  257. $this->lastException = new InvalidResourceException('The defined resource is "null".');
  258. return $this;
  259. }
  260. if ($identifier instanceof FilesModel) {
  261. return $this->fromFilesModel($identifier);
  262. }
  263. if ($identifier instanceof ImageInterface) {
  264. return $this->fromImage($identifier);
  265. }
  266. $isString = \is_string($identifier);
  267. if ($isString && $this->getValidatorAdapter()->isUuid($identifier)) {
  268. return $this->fromUuid($identifier);
  269. }
  270. if (is_numeric($identifier)) {
  271. return $this->fromId((int) $identifier);
  272. }
  273. if ($isString) {
  274. return $this->fromPath($identifier);
  275. }
  276. $type = \is_object($identifier) ? \get_class($identifier) : \gettype($identifier);
  277. throw new \TypeError(sprintf('%s(): Argument #1 ($identifier) must be of type FilesModel|ImageInterface|string|int|null, %s given', __METHOD__, $type));
  278. }
  279. /**
  280. * Sets a size configuration that will be applied to the resource.
  281. *
  282. * @param int|string|array|PictureConfiguration|null $size A picture size configuration or reference
  283. */
  284. public function setSize($size): self
  285. {
  286. $this->sizeConfiguration = $size;
  287. return $this;
  288. }
  289. /**
  290. * Sets resize options.
  291. *
  292. * By default, or if the argument is set to null, resize options are derived
  293. * from predefined image sizes.
  294. */
  295. public function setResizeOptions(?ResizeOptions $resizeOptions): self
  296. {
  297. $this->resizeOptions = $resizeOptions;
  298. return $this;
  299. }
  300. /**
  301. * Sets custom metadata.
  302. *
  303. * By default, or if the argument is set to null, metadata is trying to be
  304. * pulled from the FilesModel.
  305. */
  306. public function setMetadata(?Metadata $metadata): self
  307. {
  308. $this->metadata = $metadata;
  309. return $this;
  310. }
  311. /**
  312. * Sets custom overwrite metadata.
  313. *
  314. * The metadata will be merged with the default metadata from the FilesModel.
  315. */
  316. public function setOverwriteMetadata(?Metadata $metadata): self
  317. {
  318. $this->overwriteMetadata = $metadata;
  319. return $this;
  320. }
  321. /**
  322. * Disables creating/using metadata in the output even if it is present.
  323. */
  324. public function disableMetadata(bool $disable = true): self
  325. {
  326. $this->disableMetadata = $disable;
  327. return $this;
  328. }
  329. /**
  330. * Sets a custom locale.
  331. *
  332. * By default, or if the argument is set to null, the locale is determined
  333. * from the request context and/or system settings.
  334. */
  335. public function setLocale(?string $locale): self
  336. {
  337. $this->locale = $locale;
  338. return $this;
  339. }
  340. /**
  341. * Adds a custom link attribute.
  342. *
  343. * Set the value to null to remove it. If you want to explicitly remove an
  344. * auto-generated value from the results, set the $forceRemove flag to true.
  345. */
  346. public function setLinkAttribute(string $attribute, ?string $value, bool $forceRemove = false): self
  347. {
  348. if (null !== $value || $forceRemove) {
  349. $this->additionalLinkAttributes[$attribute] = $value;
  350. } else {
  351. unset($this->additionalLinkAttributes[$attribute]);
  352. }
  353. return $this;
  354. }
  355. /**
  356. * Sets all custom link attributes as an associative array.
  357. *
  358. * This will overwrite previously set attributes. If you want to explicitly
  359. * remove an auto-generated value from the results, set the respective
  360. * attribute to null.
  361. */
  362. public function setLinkAttributes(array $attributes): self
  363. {
  364. foreach ($attributes as $key => $value) {
  365. if (!\is_string($key) || !\is_string($value)) {
  366. throw new \InvalidArgumentException('Link attributes must be an array of type <string, string>.');
  367. }
  368. }
  369. $this->additionalLinkAttributes = $attributes;
  370. return $this;
  371. }
  372. /**
  373. * Sets the link href attribute.
  374. *
  375. * Set the value to null to use the auto-generated default.
  376. */
  377. public function setLinkHref(?string $url): self
  378. {
  379. $this->setLinkAttribute('href', $url);
  380. return $this;
  381. }
  382. /**
  383. * Sets a custom lightbox resource (file path or ImageInterface) or URL.
  384. *
  385. * By default, or if the argument is set to null, the image/target will be
  386. * automatically determined from the metadata or base resource. For this
  387. * setting to take effect, make sure you have enabled the creation of a
  388. * lightbox by calling enableLightbox().
  389. *
  390. * @param string|ImageInterface|null $resourceOrUrl
  391. */
  392. public function setLightboxResourceOrUrl($resourceOrUrl): self
  393. {
  394. $this->lightboxResourceOrUrl = $resourceOrUrl;
  395. return $this;
  396. }
  397. /**
  398. * Sets a size configuration that will be applied to the lightbox image.
  399. *
  400. * For this setting to take effect, make sure you have enabled the creation
  401. * of a lightbox by calling enableLightbox().
  402. *
  403. * @param int|string|array|PictureConfiguration|null $size A picture size configuration or reference
  404. */
  405. public function setLightboxSize($size): self
  406. {
  407. $this->lightboxSizeConfiguration = $size;
  408. return $this;
  409. }
  410. /**
  411. * Sets resize options for the lightbox image.
  412. *
  413. * By default, or if the argument is set to null, resize options are derived
  414. * from predefined image sizes.
  415. */
  416. public function setLightboxResizeOptions(?ResizeOptions $resizeOptions): self
  417. {
  418. $this->lightboxResizeOptions = $resizeOptions;
  419. return $this;
  420. }
  421. /**
  422. * Sets a custom lightbox group ID.
  423. *
  424. * By default, or if the argument is set to null, the ID will be empty. For
  425. * this setting to take effect, make sure you have enabled the creation of
  426. * a lightbox by calling enableLightbox().
  427. */
  428. public function setLightboxGroupIdentifier(?string $identifier): self
  429. {
  430. $this->lightboxGroupIdentifier = $identifier;
  431. return $this;
  432. }
  433. /**
  434. * Enables the creation of a lightbox image (if possible) and/or
  435. * outputting the respective link attributes.
  436. *
  437. * This setting is disabled by default.
  438. */
  439. public function enableLightbox(bool $enable = true): self
  440. {
  441. $this->enableLightbox = $enable;
  442. return $this;
  443. }
  444. /**
  445. * Sets all template options as an associative array.
  446. */
  447. public function setOptions(array $options): self
  448. {
  449. $this->options = $options;
  450. return $this;
  451. }
  452. /**
  453. * Returns the last InvalidResourceException that was captured when setting
  454. * resources or null if there was none.
  455. */
  456. public function getLastException(): ?InvalidResourceException
  457. {
  458. return $this->lastException;
  459. }
  460. /**
  461. * @internal
  462. */
  463. public function setLastException(InvalidResourceException $exception): self
  464. {
  465. $this->lastException = $exception;
  466. return $this;
  467. }
  468. /**
  469. * Creates a result object with the current settings, throws an exception
  470. * if the currently defined resource is invalid.
  471. *
  472. * @throws InvalidResourceException
  473. */
  474. public function build(): Figure
  475. {
  476. if (null !== $this->lastException) {
  477. throw $this->lastException;
  478. }
  479. return $this->doBuild();
  480. }
  481. /**
  482. * Creates a result object with the current settings, returns null if the
  483. * currently defined resource is invalid.
  484. */
  485. public function buildIfResourceExists(): ?Figure
  486. {
  487. if (null !== $this->lastException) {
  488. return null;
  489. }
  490. $figure = $this->doBuild();
  491. try {
  492. // Make sure the resource can be processed
  493. $figure->getImage()->getOriginalDimensions();
  494. } catch (\Throwable $e) {
  495. $this->lastException = new InvalidResourceException(sprintf('The file "%s" could not be opened as an image.', $this->filePath), 0, $e);
  496. return null;
  497. }
  498. return $figure;
  499. }
  500. /**
  501. * Creates a result object with the current settings.
  502. */
  503. private function doBuild(): Figure
  504. {
  505. if (null === $this->filePath) {
  506. throw new \LogicException('You need to set a resource before building the result.');
  507. }
  508. // Freeze settings to allow reusing this builder object
  509. $settings = clone $this;
  510. $imageResult = $this->locator
  511. ->get('contao.image.studio')
  512. ->createImage($settings->filePath, $settings->sizeConfiguration, $settings->resizeOptions)
  513. ;
  514. // Define the values via closure to make their evaluation lazy
  515. return new Figure(
  516. $imageResult,
  517. \Closure::bind(
  518. function (Figure $figure): ?Metadata {
  519. $event = new FileMetadataEvent($this->onDefineMetadata());
  520. $this->locator->get('event_dispatcher')->dispatch($event);
  521. return $event->getMetadata();
  522. },
  523. $settings
  524. ),
  525. \Closure::bind(
  526. fn (Figure $figure): array => $this->onDefineLinkAttributes($figure),
  527. $settings
  528. ),
  529. \Closure::bind(
  530. fn (Figure $figure): ?LightboxResult => $this->onDefineLightboxResult($figure),
  531. $settings
  532. ),
  533. $settings->options
  534. );
  535. }
  536. /**
  537. * Defines metadata on demand.
  538. */
  539. private function onDefineMetadata(): ?Metadata
  540. {
  541. if ($this->disableMetadata) {
  542. return null;
  543. }
  544. $getUuid = static function (?FilesModel $filesModel): ?string {
  545. if (null === $filesModel || null === $filesModel->uuid) {
  546. return null;
  547. }
  548. // Normalize UUID to ASCII format
  549. return Validator::isBinaryUuid($filesModel->uuid)
  550. ? StringUtil::binToUuid($filesModel->uuid)
  551. : $filesModel->uuid;
  552. };
  553. $fileReferenceData = array_filter([Metadata::VALUE_UUID => $getUuid($this->filesModel)]);
  554. if (null !== $this->metadata) {
  555. return $this->metadata->with($fileReferenceData);
  556. }
  557. if (null === $this->filesModel) {
  558. return null;
  559. }
  560. // Get fallback locale list or use without fallbacks if explicitly set
  561. $locales = null !== $this->locale ? [$this->locale] : $this->getFallbackLocaleList();
  562. $metadata = $this->filesModel->getMetadata(...$locales);
  563. $overwriteMetadata = $this->overwriteMetadata ? $this->overwriteMetadata->all() : [];
  564. if (null !== $metadata) {
  565. return $metadata
  566. ->with($fileReferenceData)
  567. ->with($overwriteMetadata)
  568. ;
  569. }
  570. // If no metadata can be obtained from the model, we create a container
  571. // from the default meta fields with empty values instead
  572. $metaFields = $this->getFilesModelAdapter()->getMetaFields();
  573. $data = array_merge(
  574. array_combine($metaFields, array_fill(0, \count($metaFields), '')),
  575. $fileReferenceData
  576. );
  577. return (new Metadata($data))->with($overwriteMetadata);
  578. }
  579. /**
  580. * Defines link attributes on demand.
  581. */
  582. private function onDefineLinkAttributes(Figure $result): array
  583. {
  584. $linkAttributes = [];
  585. // Open in a new window if lightbox was requested but is invalid (fullsize)
  586. if ($this->enableLightbox && !$result->hasLightbox()) {
  587. $linkAttributes['target'] = '_blank';
  588. }
  589. return array_merge($linkAttributes, $this->additionalLinkAttributes);
  590. }
  591. /**
  592. * Defines the lightbox result (if enabled) on demand.
  593. */
  594. private function onDefineLightboxResult(Figure $result): ?LightboxResult
  595. {
  596. if (!$this->enableLightbox) {
  597. return null;
  598. }
  599. $getMetadataUrl = static function () use ($result): ?string {
  600. if (!$result->hasMetadata()) {
  601. return null;
  602. }
  603. return $result->getMetadata()->getUrl() ?: null;
  604. };
  605. /**
  606. * @param ImageInterface|string $target Image object, URL or absolute file path
  607. */
  608. $getResourceOrUrl = function ($target): array {
  609. if ($target instanceof ImageInterface) {
  610. return [$target, null];
  611. }
  612. $validExtension = \in_array(Path::getExtension($target, true), $this->validExtensions, true);
  613. $externalUrl = 1 === preg_match('#^https?://#', $target);
  614. if (!$validExtension) {
  615. return [null, null];
  616. }
  617. if ($externalUrl) {
  618. return [null, $target];
  619. }
  620. if (Path::isAbsolute($target)) {
  621. $filePath = Path::canonicalize($target);
  622. } else {
  623. // URL relative to the project directory
  624. $filePath = Path::makeAbsolute(urldecode($target), $this->projectDir);
  625. }
  626. if (!is_file($filePath)) {
  627. $filePath = null;
  628. }
  629. return [$filePath, null];
  630. };
  631. // Use explicitly set href (1) or lightbox resource (2), fall back to using metadata (3) or use the base resource (4) if empty
  632. $lightboxResourceOrUrl = $this->additionalLinkAttributes['href'] ?? $this->lightboxResourceOrUrl ?? $getMetadataUrl() ?? $this->filePath;
  633. [$filePathOrImage, $url] = $getResourceOrUrl($lightboxResourceOrUrl);
  634. if (null === $filePathOrImage && null === $url) {
  635. return null;
  636. }
  637. return $this->locator
  638. ->get('contao.image.studio')
  639. ->createLightboxImage(
  640. $filePathOrImage,
  641. $url,
  642. $this->lightboxSizeConfiguration,
  643. $this->lightboxGroupIdentifier,
  644. $this->lightboxResizeOptions
  645. )
  646. ;
  647. }
  648. /**
  649. * @return FilesModel
  650. *
  651. * @phpstan-return Adapter<FilesModel>
  652. */
  653. private function getFilesModelAdapter(): Adapter
  654. {
  655. $framework = $this->locator->get('contao.framework');
  656. $framework->initialize();
  657. return $framework->getAdapter(FilesModel::class);
  658. }
  659. /**
  660. * @return Validator
  661. *
  662. * @phpstan-return Adapter<Validator>
  663. */
  664. private function getValidatorAdapter(): Adapter
  665. {
  666. $framework = $this->locator->get('contao.framework');
  667. $framework->initialize();
  668. return $framework->getAdapter(Validator::class);
  669. }
  670. /**
  671. * Returns a list of locales (if available) in the following order:
  672. * 1. language of current page,
  673. * 2. root page fallback language.
  674. */
  675. private function getFallbackLocaleList(): array
  676. {
  677. $page = $GLOBALS['objPage'] ?? null;
  678. if (!$page instanceof PageModel) {
  679. return [];
  680. }
  681. $locales = [LocaleUtil::formatAsLocale($page->language)];
  682. if (null !== $page->rootFallbackLanguage) {
  683. $locales[] = LocaleUtil::formatAsLocale($page->rootFallbackLanguage);
  684. }
  685. return array_unique(array_filter($locales));
  686. }
  687. }