From 76f6bbaf254652a5b25a4885e16eb965d37fe36c Mon Sep 17 00:00:00 2001 From: just_like_good_vibes <mickael@meulle.com> Date: Wed, 12 Mar 2025 14:48:44 +0100 Subject: [PATCH 1/5] WIP --- src/Element/ComponentElementBuilder.php | 110 +++++++++++++++--------- 1 file changed, 68 insertions(+), 42 deletions(-) diff --git a/src/Element/ComponentElementBuilder.php b/src/Element/ComponentElementBuilder.php index d890b359..87f917e2 100644 --- a/src/Element/ComponentElementBuilder.php +++ b/src/Element/ComponentElementBuilder.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Drupal\ui_patterns\Element; +use Drupal\ui_patterns\Plugin\UiPatterns\PropType\SlotPropType; use Drupal\Component\Plugin\Exception\ContextException; use Drupal\Core\Plugin\Component; use Drupal\Core\Render\Element; @@ -79,42 +80,79 @@ class ComponentElementBuilder implements TrustedCallbackInterface { * Add a single prop to the renderable. */ protected function buildProp(array $build, string $prop_id, array $definition, array $configuration, array $source_contexts): array { - if (isset($build["#props"][$prop_id])) { + if (isset($build['#props'][$prop_id])) { // Keep existing props. No known use case yet. return $build; } - $source = $this->getSource($prop_id, $definition, $configuration, $source_contexts); - if (!$source) { - return $build; - } + $this->buildSource($build, $prop_id, $definition, $configuration, $source_contexts); + return $build; + } + + /** + * Alter the build array for a source. + * + * @param array $build + * The build array. + * @param string $prop_or_slot_id + * Prop ID or slot ID. + * @param array $definition + * Definition. + * @param array $configuration + * Configuration. + * @param array $contexts + * Source contexts. + * + * @return mixed + * The data returned by the source. + */ + public function buildSource(array &$build, string $prop_or_slot_id, array $definition, array $configuration, array $contexts) : mixed { try { - $build = $source->alterComponent($build); + $source = $this->getSource($prop_or_slot_id, $definition, $configuration, $contexts); + if (!$source) { + return NULL; + } + /** @var \Drupal\ui_patterns\PropTypeInterface $prop_type */ + // $prop_type = $this->propTypeManager->createInstance('slot', []); $prop_type = $definition['ui_patterns']['type_definition']; + // Alter the build array before getting the value. + $build = $source->alterComponent($build); + // Get the value from the source. $data = $source->getValue($prop_type); + // Alter the value by hook implementations. $this->moduleHandler->alter('ui_patterns_source_value', $data, $source, $configuration); - if (empty($data) && $prop_type->getPluginId() !== 'attributes') { - // For JSON Schema validator, empty value is not the same as missing - // value, and we want to prevent some of the prop types rules to be - // applied on empty values: string pattern, string format, enum, number - // min/max... - // However, we don't remove empty attributes to avoid an error with - // Drupal\Core\Template\TwigExtension::createAttribute() when themers - // forget to use the default({}) filter. - return $build; + if ($prop_type instanceof SlotPropType) { + if ($data !== NULL && Element::isRenderArray($data)) { + if ($this->isSingletonRenderArray($data)) { + $data = array_values($data)[0]; + } + $build['#slots'][$prop_or_slot_id][] = $data; + } + } + else { + if (!empty($data) || $prop_type->getPluginId() === 'attributes') { + // For JSON Schema validator, empty value is not the same as missing + // value, and we want to prevent some of the prop types rules to be + // applied on empty values: string pattern, string format, enum, number + // min/max... + // However, we don't remove empty attributes to avoid an error with + // Drupal\Core\Template\TwigExtension::createAttribute() when themers + // forget to use the default({}) filter. + $build['#props'][$prop_or_slot_id] = $data; + } } - $build["#props"][$prop_id] = $data; + return $data; } catch (ContextException $e) { // ContextException is thrown when a required context is missing. // We don't want to break the render process, so we just ignore the prop. - $error_message = t("Context error for prop '@prop_id' in component '@component_id': @message", [ - '@prop_id' => $prop_id, + $error_message = t("Context error for '@prop_id' in component '@component_id': @message", [ + '@prop_id' => $prop_or_slot_id, '@component_id' => $build['#component'], '@message' => $e->getMessage(), ]); $this->logger->error($error_message); } - return $build; + return NULL; } /** @@ -134,8 +172,8 @@ class ComponentElementBuilder implements TrustedCallbackInterface { * * @throws \Drupal\Component\Plugin\Exception\PluginException */ - protected function getSource(string $prop_or_slot_id, array $definition, array $configuration, array $source_contexts) : ?SourceInterface { - $source_id = $configuration["source_id"] ?? NULL; + public function getSource(string $prop_or_slot_id, array $definition, array $configuration, array $source_contexts) : ?SourceInterface { + $source_id = $configuration['source_id'] ?? NULL; if (!$source_id && isset($definition['ui_patterns']['type_definition'])) { $source_id = $this->sourcesManager->getPropTypeDefault($definition['ui_patterns']['type_definition']->getPluginId(), $source_contexts); } @@ -167,34 +205,22 @@ class ComponentElementBuilder implements TrustedCallbackInterface { * Add a single slot to the renderable. */ protected function buildSlot(array $build, string $slot_id, array $definition, array $configuration, array $contexts): array { - if (isset($build["#slots"][$slot_id])) { + if (isset($build['#slots'][$slot_id])) { // Keep existing slots. Used by ComponentLayout for example. return $build; } - if (!isset($configuration["sources"])) { + if (!isset($configuration['sources'])) { return $build; } // Slots can have many sources while props can have only one. - $build["#slots"][$slot_id] = []; - /** @var \Drupal\ui_patterns\PropTypeInterface $slot_prop_type */ - $slot_prop_type = $this->propTypeManager->createInstance("slot", []); + $build['#slots'][$slot_id] = []; // Add sources data to the slot. - foreach ($configuration["sources"] as $source_configuration) { - $source = $this->getSource($slot_id, $definition, $source_configuration, $contexts); - if (!$source) { - continue; - } - $build = $source->alterComponent($build); - $source_value = $source->getValue($slot_prop_type) ?? []; - $this->moduleHandler->alter('ui_patterns_source_value', $source_value, $source, $source_configuration); - if (Element::isRenderArray($source_value)) { - $build["#slots"][$slot_id][] = $this->isSingletonRenderArray($source_value) ? array_values($source_value)[0] : $source_value; - } + foreach ($configuration['sources'] as $source_configuration) { + $this->buildSource($build, $slot_id, $definition, $source_configuration, $contexts); } - if ($this->isSingletonRenderArray($build["#slots"][$slot_id])) { - $build["#slots"][$slot_id] = $build["#slots"][$slot_id][0]; + if ($this->isSingletonRenderArray($build['#slots'][$slot_id])) { + $build['#slots'][$slot_id] = $build['#slots'][$slot_id][0]; } - return $build; } @@ -294,10 +320,10 @@ class ComponentElementBuilder implements TrustedCallbackInterface { $slots = $component->metadata->slots ?? []; foreach ($slots as $slot_id => $definition) { $slot_configuration = $configuration['slots'][$slot_id] ?? []; - if (!isset($slot_configuration["sources"]) || !is_array($slot_configuration["sources"])) { + if (!isset($slot_configuration['sources']) || !is_array($slot_configuration['sources'])) { continue; } - foreach ($slot_configuration["sources"] as $source_configuration) { + foreach ($slot_configuration['sources'] as $source_configuration) { if ($source = $this->getSource($slot_id, $definition, $source_configuration, $contexts)) { SourcePluginBase::mergeConfigDependencies($dependencies, $source->calculateDependencies()); } -- GitLab From 45c8df8f59dbf9f429773654dec5efab96cd4441 Mon Sep 17 00:00:00 2001 From: just_like_good_vibes <mickael@meulle.com> Date: Wed, 12 Mar 2025 15:25:37 +0100 Subject: [PATCH 2/5] WIP --- src/Element/ComponentElementBuilder.php | 69 +++++++++++++++---------- 1 file changed, 41 insertions(+), 28 deletions(-) diff --git a/src/Element/ComponentElementBuilder.php b/src/Element/ComponentElementBuilder.php index 87f917e2..4ef47d87 100644 --- a/src/Element/ComponentElementBuilder.php +++ b/src/Element/ComponentElementBuilder.php @@ -11,6 +11,7 @@ use Drupal\Core\Render\Element; use Drupal\Core\Security\TrustedCallbackInterface; use Drupal\Core\Theme\ComponentPluginManager; use Drupal\ui_patterns\ComponentPluginManager as UiPatternsComponentPluginManager; +use Drupal\ui_patterns\PropTypeInterface; use Drupal\ui_patterns\PropTypePluginManager; use Drupal\ui_patterns\SourceInterface; use Drupal\ui_patterns\SourcePluginBase; @@ -84,8 +85,33 @@ class ComponentElementBuilder implements TrustedCallbackInterface { // Keep existing props. No known use case yet. return $build; } - $this->buildSource($build, $prop_id, $definition, $configuration, $source_contexts); - return $build; + return $this->buildSource($build, $prop_id, $definition, $configuration, $source_contexts); + } + + /** + * Add data to a prop or a slot. + */ + protected function addDataToComponent(array &$build, string $prop_or_slot_id, PropTypeInterface $prop_type, mixed $data): void { + if ($prop_type instanceof SlotPropType) { + if ($data !== NULL && Element::isRenderArray($data)) { + if ($this->isSingletonRenderArray($data)) { + $data = array_values($data)[0]; + } + $build['#slots'][$prop_or_slot_id][] = $data; + } + } + else { + if (!empty($data) || $prop_type->getPluginId() === 'attributes') { + // For JSON Schema validator, empty value is not the same as missing + // value, and we want to prevent some of the prop types rules to be + // applied on empty values: string pattern, string format, + // enum, number min/max... + // However, we don't remove empty attributes to avoid an error with + // Drupal\Core\Template\TwigExtension::createAttribute() when themers + // forget to use the default({}) filter. + $build['#props'][$prop_or_slot_id] = $data; + } + } } /** @@ -105,42 +131,25 @@ class ComponentElementBuilder implements TrustedCallbackInterface { * @return mixed * The data returned by the source. */ - public function buildSource(array &$build, string $prop_or_slot_id, array $definition, array $configuration, array $contexts) : mixed { + public function buildSource(array $build, string $prop_or_slot_id, array $definition, array $configuration, array $contexts) : mixed { try { + if (empty($configuration['source_id'])) { + return $build; + } $source = $this->getSource($prop_or_slot_id, $definition, $configuration, $contexts); if (!$source) { - return NULL; + return $build; } /** @var \Drupal\ui_patterns\PropTypeInterface $prop_type */ // $prop_type = $this->propTypeManager->createInstance('slot', []); - $prop_type = $definition['ui_patterns']['type_definition']; + $prop_type = $source->getPropDefinition()['ui_patterns']['type_definition']; // Alter the build array before getting the value. $build = $source->alterComponent($build); // Get the value from the source. $data = $source->getValue($prop_type); // Alter the value by hook implementations. $this->moduleHandler->alter('ui_patterns_source_value', $data, $source, $configuration); - if ($prop_type instanceof SlotPropType) { - if ($data !== NULL && Element::isRenderArray($data)) { - if ($this->isSingletonRenderArray($data)) { - $data = array_values($data)[0]; - } - $build['#slots'][$prop_or_slot_id][] = $data; - } - } - else { - if (!empty($data) || $prop_type->getPluginId() === 'attributes') { - // For JSON Schema validator, empty value is not the same as missing - // value, and we want to prevent some of the prop types rules to be - // applied on empty values: string pattern, string format, enum, number - // min/max... - // However, we don't remove empty attributes to avoid an error with - // Drupal\Core\Template\TwigExtension::createAttribute() when themers - // forget to use the default({}) filter. - $build['#props'][$prop_or_slot_id] = $data; - } - } - return $data; + $this->addDataToComponent($build, $prop_or_slot_id, $prop_type, $data); } catch (ContextException $e) { // ContextException is thrown when a required context is missing. @@ -152,7 +161,7 @@ class ComponentElementBuilder implements TrustedCallbackInterface { ]); $this->logger->error($error_message); } - return NULL; + return $build; } /** @@ -173,6 +182,10 @@ class ComponentElementBuilder implements TrustedCallbackInterface { * @throws \Drupal\Component\Plugin\Exception\PluginException */ public function getSource(string $prop_or_slot_id, array $definition, array $configuration, array $source_contexts) : ?SourceInterface { + if (empty($definition)) { + // We consider a slot if no definition is provided. + $definition = ['ui_patterns' => ['type_definition' => $this->propTypeManager->createInstance('slot')]]; + } $source_id = $configuration['source_id'] ?? NULL; if (!$source_id && isset($definition['ui_patterns']['type_definition'])) { $source_id = $this->sourcesManager->getPropTypeDefault($definition['ui_patterns']['type_definition']->getPluginId(), $source_contexts); @@ -216,7 +229,7 @@ class ComponentElementBuilder implements TrustedCallbackInterface { $build['#slots'][$slot_id] = []; // Add sources data to the slot. foreach ($configuration['sources'] as $source_configuration) { - $this->buildSource($build, $slot_id, $definition, $source_configuration, $contexts); + $build = $this->buildSource($build, $slot_id, $definition, $source_configuration, $contexts); } if ($this->isSingletonRenderArray($build['#slots'][$slot_id])) { $build['#slots'][$slot_id] = $build['#slots'][$slot_id][0]; -- GitLab From d1ba67f3b448375feccab2c8ba15cce13777575a Mon Sep 17 00:00:00 2001 From: just_like_good_vibes <mickael@meulle.com> Date: Wed, 12 Mar 2025 15:26:52 +0100 Subject: [PATCH 3/5] WIP --- src/Element/ComponentElementBuilder.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Element/ComponentElementBuilder.php b/src/Element/ComponentElementBuilder.php index 4ef47d87..8cce68ea 100644 --- a/src/Element/ComponentElementBuilder.php +++ b/src/Element/ComponentElementBuilder.php @@ -115,7 +115,7 @@ class ComponentElementBuilder implements TrustedCallbackInterface { } /** - * Alter the build array for a source. + * Alter the build array for a configured source on a prop/slot. * * @param array $build * The build array. @@ -165,7 +165,7 @@ class ComponentElementBuilder implements TrustedCallbackInterface { } /** - * Get Source plugin for a prop. + * Get Source plugin for a prop or slot. * * @param string $prop_or_slot_id * Prop ID or slot ID. -- GitLab From 2c948ada9a62231138824a851ed13fb8f077a383 Mon Sep 17 00:00:00 2001 From: just_like_good_vibes <mickael@meulle.com> Date: Wed, 12 Mar 2025 16:25:57 +0100 Subject: [PATCH 4/5] comments --- src/Element/ComponentElementBuilder.php | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/Element/ComponentElementBuilder.php b/src/Element/ComponentElementBuilder.php index 8cce68ea..0d4b882f 100644 --- a/src/Element/ComponentElementBuilder.php +++ b/src/Element/ComponentElementBuilder.php @@ -115,7 +115,7 @@ class ComponentElementBuilder implements TrustedCallbackInterface { } /** - * Alter the build array for a configured source on a prop/slot. + * Update the build array for a configured source on a prop/slot. * * @param array $build * The build array. @@ -129,7 +129,7 @@ class ComponentElementBuilder implements TrustedCallbackInterface { * Source contexts. * * @return mixed - * The data returned by the source. + * The updated build array. */ public function buildSource(array $build, string $prop_or_slot_id, array $definition, array $configuration, array $contexts) : mixed { try { @@ -170,18 +170,20 @@ class ComponentElementBuilder implements TrustedCallbackInterface { * @param string $prop_or_slot_id * Prop ID or slot ID. * @param array $definition - * Definition. + * Definition (if empty, slot will be automatically set). * @param array $configuration - * Configuration. + * Configuration for the source. * @param array $source_contexts * Source contexts. + * @param array $form_array_parents + * Form array parents. * * @return \Drupal\ui_patterns\SourceInterface|null - * The source found or NULL. + * The source found and instantiated or NULL. * * @throws \Drupal\Component\Plugin\Exception\PluginException */ - public function getSource(string $prop_or_slot_id, array $definition, array $configuration, array $source_contexts) : ?SourceInterface { + public function getSource(string $prop_or_slot_id, array $definition, array $configuration, array $source_contexts, array $form_array_parents = []) : ?SourceInterface { if (empty($definition)) { // We consider a slot if no definition is provided. $definition = ['ui_patterns' => ['type_definition' => $this->propTypeManager->createInstance('slot')]]; @@ -196,7 +198,7 @@ class ComponentElementBuilder implements TrustedCallbackInterface { /** @var \Drupal\ui_patterns\SourceInterface $source */ $source = $this->sourcesManager->createInstance( $source_id, - SourcePluginBase::buildConfiguration($prop_or_slot_id, $definition, $configuration, $source_contexts) + SourcePluginBase::buildConfiguration($prop_or_slot_id, $definition, $configuration, $source_contexts, $form_array_parents) ); return $source; } -- GitLab From 639eb831b91cdc368ff45031d3aa8181fbba3b86 Mon Sep 17 00:00:00 2001 From: just_like_good_vibes <mickael@meulle.com> Date: Thu, 13 Mar 2025 09:44:50 +0100 Subject: [PATCH 5/5] moved getSource to source manager --- src/Element/ComponentElementBuilder.php | 49 ++---------------------- src/SourcePluginManager.php | 51 +++++++++++++++++++++++++ ui_patterns.services.yml | 1 - 3 files changed, 54 insertions(+), 47 deletions(-) diff --git a/src/Element/ComponentElementBuilder.php b/src/Element/ComponentElementBuilder.php index 0d4b882f..e2948b34 100644 --- a/src/Element/ComponentElementBuilder.php +++ b/src/Element/ComponentElementBuilder.php @@ -12,8 +12,6 @@ use Drupal\Core\Security\TrustedCallbackInterface; use Drupal\Core\Theme\ComponentPluginManager; use Drupal\ui_patterns\ComponentPluginManager as UiPatternsComponentPluginManager; use Drupal\ui_patterns\PropTypeInterface; -use Drupal\ui_patterns\PropTypePluginManager; -use Drupal\ui_patterns\SourceInterface; use Drupal\ui_patterns\SourcePluginBase; use Drupal\ui_patterns\SourcePluginManager; use Psr\Log\LoggerInterface; @@ -36,7 +34,6 @@ class ComponentElementBuilder implements TrustedCallbackInterface { */ public function __construct( protected SourcePluginManager $sourcesManager, - protected PropTypePluginManager $propTypeManager, protected ComponentPluginManager $componentPluginManager, protected ModuleHandlerInterface $moduleHandler, protected LoggerInterface $logger, @@ -136,12 +133,11 @@ class ComponentElementBuilder implements TrustedCallbackInterface { if (empty($configuration['source_id'])) { return $build; } - $source = $this->getSource($prop_or_slot_id, $definition, $configuration, $contexts); + $source = $this->sourcesManager->getSource($prop_or_slot_id, $definition, $configuration, $contexts); if (!$source) { return $build; } /** @var \Drupal\ui_patterns\PropTypeInterface $prop_type */ - // $prop_type = $this->propTypeManager->createInstance('slot', []); $prop_type = $source->getPropDefinition()['ui_patterns']['type_definition']; // Alter the build array before getting the value. $build = $source->alterComponent($build); @@ -164,45 +160,6 @@ class ComponentElementBuilder implements TrustedCallbackInterface { return $build; } - /** - * Get Source plugin for a prop or slot. - * - * @param string $prop_or_slot_id - * Prop ID or slot ID. - * @param array $definition - * Definition (if empty, slot will be automatically set). - * @param array $configuration - * Configuration for the source. - * @param array $source_contexts - * Source contexts. - * @param array $form_array_parents - * Form array parents. - * - * @return \Drupal\ui_patterns\SourceInterface|null - * The source found and instantiated or NULL. - * - * @throws \Drupal\Component\Plugin\Exception\PluginException - */ - public function getSource(string $prop_or_slot_id, array $definition, array $configuration, array $source_contexts, array $form_array_parents = []) : ?SourceInterface { - if (empty($definition)) { - // We consider a slot if no definition is provided. - $definition = ['ui_patterns' => ['type_definition' => $this->propTypeManager->createInstance('slot')]]; - } - $source_id = $configuration['source_id'] ?? NULL; - if (!$source_id && isset($definition['ui_patterns']['type_definition'])) { - $source_id = $this->sourcesManager->getPropTypeDefault($definition['ui_patterns']['type_definition']->getPluginId(), $source_contexts); - } - if (!$source_id) { - return NULL; - } - /** @var \Drupal\ui_patterns\SourceInterface $source */ - $source = $this->sourcesManager->createInstance( - $source_id, - SourcePluginBase::buildConfiguration($prop_or_slot_id, $definition, $configuration, $source_contexts, $form_array_parents) - ); - return $source; - } - /** * Add slots to the renderable. */ @@ -310,7 +267,7 @@ class ComponentElementBuilder implements TrustedCallbackInterface { if ($prop_id === 'variant') { continue; } - if ($source = $this->getSource($prop_id, $definition, $configuration['props'][$prop_id] ?? [], $contexts)) { + if ($source = $this->sourcesManager->getSource($prop_id, $definition, $configuration['props'][$prop_id] ?? [], $contexts)) { SourcePluginBase::mergeConfigDependencies($dependencies, $source->calculateDependencies()); } } @@ -339,7 +296,7 @@ class ComponentElementBuilder implements TrustedCallbackInterface { continue; } foreach ($slot_configuration['sources'] as $source_configuration) { - if ($source = $this->getSource($slot_id, $definition, $source_configuration, $contexts)) { + if ($source = $this->sourcesManager->getSource($slot_id, $definition, $source_configuration, $contexts)) { SourcePluginBase::mergeConfigDependencies($dependencies, $source->calculateDependencies()); } } diff --git a/src/SourcePluginManager.php b/src/SourcePluginManager.php index 32a42c61..de07ef0d 100644 --- a/src/SourcePluginManager.php +++ b/src/SourcePluginManager.php @@ -325,4 +325,55 @@ class SourcePluginManager extends DefaultPluginManager implements ContextAwarePl return isset($definitions[$source_id]); } + /** + * Get a source plugin Instance. + * + * A source instance is always related to a prop or a slot. + * That's why we pass first the prop or slot id and the associated definition. + * If definition is empty, the slot will be automatically assumed. + * The configuration passed is the source configuration. + * It has a key 'source_id' that is the source plugin identifier. + * When no source_id is provided, + * the default source for the prop type is used. + * The source contexts are the contexts currently in use, + * maybe needed for that source or not. + * The form array parents are the form array parents, needed + * when dealing with the source settingsForm. + * + * @param string $prop_or_slot_id + * Prop ID or slot ID. + * @param array $definition + * Definition (if empty, slot will be automatically set). + * @param array $configuration + * Configuration for the source. + * @param array $source_contexts + * Source contexts. + * @param array $form_array_parents + * Form array parents. + * + * @return \Drupal\ui_patterns\SourceInterface|null + * The source found and instantiated or NULL. + * + * @throws \Drupal\Component\Plugin\Exception\PluginException + */ + public function getSource(string $prop_or_slot_id, array $definition, array $configuration, array $source_contexts = [], array $form_array_parents = []) : ?SourceInterface { + if (empty($definition)) { + // We consider a slot if no definition is provided. + $definition = ['ui_patterns' => ['type_definition' => $this->propTypeManager->createInstance('slot')]]; + } + $source_id = $configuration['source_id'] ?? NULL; + if (!$source_id && isset($definition['ui_patterns']['type_definition'])) { + $source_id = $this->getPropTypeDefault($definition['ui_patterns']['type_definition']->getPluginId(), $source_contexts); + } + if (!$source_id) { + return NULL; + } + /** @var \Drupal\ui_patterns\SourceInterface $source */ + $source = $this->createInstance( + $source_id, + SourcePluginBase::buildConfiguration($prop_or_slot_id, $definition, $configuration, $source_contexts, $form_array_parents) + ); + return $source; + } + } diff --git a/ui_patterns.services.yml b/ui_patterns.services.yml index ac131fbe..fc03ba97 100644 --- a/ui_patterns.services.yml +++ b/ui_patterns.services.yml @@ -44,7 +44,6 @@ services: class: Drupal\ui_patterns\Element\ComponentElementBuilder arguments: - "@plugin.manager.ui_patterns_source" - - "@plugin.manager.ui_patterns_prop_type" - "@plugin.manager.sdc" - "@module_handler" - "@logger.channel.ui_patterns" -- GitLab