diff --git a/core/config/schema/core.data_types.schema.yml b/core/config/schema/core.data_types.schema.yml index 73db361..0fbeeed 100644 --- a/core/config/schema/core.data_types.schema.yml +++ b/core/config/schema/core.data_types.schema.yml @@ -361,6 +361,13 @@ display_variant.plugin: type: string label: 'UUID' +layout_plugin.settings: + type: mapping + label: 'Layout settings' + +layout_plugin.settings.*: + type: layout_plugin.settings + base_entity_reference_field_settings: type: mapping mapping: diff --git a/core/core.api.php b/core/core.api.php index 639e55d..40b85b2 100644 --- a/core/core.api.php +++ b/core/core.api.php @@ -2132,6 +2132,17 @@ function hook_display_variant_plugin_alter(array &$definitions) { } /** + * Allow modules to alter layout plugin definitions. + * + * @param \Drupal\Core\Layout\LayoutDefinition[] $definitions + * The array of layout definitions, keyed by plugin ID. + */ +function hook_layout_alter(&$definitions) { + // Remove a layout. + unset($definitions['twocol']); +} + +/** * Flush all persistent and static caches. * * This hook asks your module to clear all of its static caches, diff --git a/core/core.services.yml b/core/core.services.yml index 5c42b23..d744fa1 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -633,6 +633,9 @@ services: plugin.manager.display_variant: class: Drupal\Core\Display\VariantManager parent: default_plugin_manager + plugin.manager.layout: + class: Drupal\Core\Layout\LayoutPluginManager + arguments: ['@container.namespaces', '@cache.discovery', '@module_handler', '@theme_handler'] plugin.manager.queue_worker: class: Drupal\Core\Queue\QueueWorkerManager parent: default_plugin_manager diff --git a/core/lib/Drupal/Core/Layout/Annotation/Layout.php b/core/lib/Drupal/Core/Layout/Annotation/Layout.php new file mode 100644 index 0000000..8a40f63 --- /dev/null +++ b/core/lib/Drupal/Core/Layout/Annotation/Layout.php @@ -0,0 +1,152 @@ +definition); + } + +} diff --git a/core/lib/Drupal/Core/Layout/DerivablePluginDefinitionInterface.php b/core/lib/Drupal/Core/Layout/DerivablePluginDefinitionInterface.php new file mode 100644 index 0000000..047c572 --- /dev/null +++ b/core/lib/Drupal/Core/Layout/DerivablePluginDefinitionInterface.php @@ -0,0 +1,37 @@ +setConfiguration($configuration); + } + + /** + * {@inheritdoc} + */ + public function build(array $regions) { + $build['#content'] = array_intersect_key($regions, $this->pluginDefinition->getRegions()); + $build['#process'][] = [static::class, 'processContent']; + $build['#settings'] = $this->getConfiguration(); + $build['#layout'] = $this->pluginDefinition; + $build['#theme'] = $this->pluginDefinition->getThemeHook(); + if ($library = $this->pluginDefinition->getLibrary()) { + $build['#attached']['library'][] = $library; + } + return $build; + } + + /** + * Moves the content so that it can be processed properly by the form builder. + * + * In the ::build() method the layout content is stored in #content, as + * expected by the #theme hook. However layouts may contain forms, and form + * building ignores #-prefixed properties. Temporarily move this data to a + * non-#-prefixed location to allow it to be processed. + * + * @param array $element + * The form element being processed. + * + * @return array + * The form element that has been processed. + * + * @see ::preRenderContent() + */ + public static function processContent($element) { + $element['#pre_render'][] = [static::class, 'preRenderContent']; + $element['content'] = $element['#content']; + unset($element['#content']); + return $element; + } + + /** + * Moves the content back to the location expected by the #theme hook. + * + * @param array $element + * An associative array containing the properties and children of the + * element. + * + * @return array + * The modified element. + * + * @see ::processContent() + */ + public static function preRenderContent($element) { + $element['#content'] = $element['content']; + unset($element['content']); + return $element; + } + + /** + * {@inheritdoc} + */ + public function getConfiguration() { + return $this->configuration; + } + + /** + * {@inheritdoc} + */ + public function setConfiguration(array $configuration) { + $this->configuration = NestedArray::mergeDeep($this->defaultConfiguration(), $configuration); + } + + /** + * {@inheritdoc} + */ + public function defaultConfiguration() { + return []; + } + + /** + * {@inheritdoc} + */ + public function calculateDependencies() { + return []; + } + + /** + * {@inheritdoc} + * + * @return \Drupal\Core\Layout\LayoutDefinition + */ + public function getPluginDefinition() { + return parent::getPluginDefinition(); + } + +} diff --git a/core/lib/Drupal/Core/Layout/LayoutDefinition.php b/core/lib/Drupal/Core/Layout/LayoutDefinition.php new file mode 100644 index 0000000..58d46f3 --- /dev/null +++ b/core/lib/Drupal/Core/Layout/LayoutDefinition.php @@ -0,0 +1,547 @@ + $value) { + $this->set($property, $value); + } + } + + /** + * Gets any arbitrary property. + * + * @param string $property + * The property to retrieve. + * + * @return mixed + * The value for that property, or NULL if the property does not exist. + */ + public function get($property) { + if (property_exists($this, $property)) { + $value = isset($this->{$property}) ? $this->{$property} : NULL; + } + else { + $value = isset($this->additional[$property]) ? $this->additional[$property] : NULL; + } + return $value; + } + + /** + * Sets a value to an arbitrary property. + * + * @param string $property + * The property to use for the value. + * @param mixed $value + * The value to set. + * + * @return $this + */ + public function set($property, $value) { + if (property_exists($this, $property)) { + $this->{$property} = $value; + } + else { + $this->additional[$property] = $value; + } + return $this; + } + + /** + * Gets the unique identifier of the layout definition. + * + * @return string + * The unique identifier of the layout definition. + */ + public function id() { + return $this->id; + } + + /** + * {@inheritdoc} + */ + public function getClass() { + return $this->class; + } + + /** + * {@inheritdoc} + */ + public function setClass($class) { + $this->class = $class; + return $this; + } + + /** + * Gets the human-readable name of the layout definition. + * + * @return string|\Drupal\Core\StringTranslation\TranslatableMarkup + * The human-readable name of the layout definition. + */ + public function getLabel() { + return $this->label; + } + + /** + * Sets the human-readable name of the layout definition. + * + * @param string|\Drupal\Core\StringTranslation\TranslatableMarkup $label + * The human-readable name of the layout definition. + * + * @return $this + */ + public function setLabel($label) { + $this->label = $label; + return $this; + } + + /** + * Gets the description of the layout definition. + * + * @return string|\Drupal\Core\StringTranslation\TranslatableMarkup + * The description of the layout definition. + */ + public function getDescription() { + return $this->description; + } + + /** + * Sets the description of the layout definition. + * + * @param string|\Drupal\Core\StringTranslation\TranslatableMarkup $description + * The description of the layout definition. + * + * @return $this + */ + public function setDescription($description) { + $this->description = $description; + return $this; + } + + /** + * Gets the human-readable category of the layout definition. + * + * @return string|\Drupal\Core\StringTranslation\TranslatableMarkup + * The human-readable category of the layout definition. + */ + public function getCategory() { + return $this->category; + } + + /** + * Sets the human-readable category of the layout definition. + * + * @param string|\Drupal\Core\StringTranslation\TranslatableMarkup $category + * The human-readable category of the layout definition. + * + * @return $this + */ + public function setCategory($category) { + $this->category = $category; + return $this; + } + + /** + * Gets the template name. + * + * @return string|null + * The template name, if it exists. + */ + public function getTemplate() { + return $this->template; + } + + /** + * Sets the template name. + * + * @param string|null $template + * The template name. + * + * @return $this + */ + public function setTemplate($template) { + $this->template = $template; + return $this; + } + + /** + * Gets the template path. + * + * @return string + * The template path. + */ + public function getTemplatePath() { + return $this->templatePath; + } + + /** + * Sets the template path. + * + * @param string $template_path + * The template path. + * + * @return $this + */ + public function setTemplatePath($template_path) { + $this->templatePath = $template_path; + return $this; + } + + /** + * Gets the theme hook. + * + * @return string|null + * The theme hook, if it exists. + */ + public function getThemeHook() { + return $this->theme_hook; + } + + /** + * Sets the theme hook. + * + * @param string $theme_hook + * The theme hook. + * + * @return $this + */ + public function setThemeHook($theme_hook) { + $this->theme_hook = $theme_hook; + return $this; + } + + /** + * Gets the base path for this layout definition. + * + * @return string + * The base path. + */ + public function getPath() { + return $this->path; + } + + /** + * Sets the base path for this layout definition. + * + * @param string $path + * The base path. + * + * @return $this + */ + public function setPath($path) { + $this->path = $path; + return $this; + } + + /** + * Gets the asset library for this layout definition. + * + * @return string|null + * The asset library, if it exists. + */ + public function getLibrary() { + return $this->library; + } + + /** + * Sets the asset library for this layout definition. + * + * @param string|null $library + * The asset library. + * + * @return $this + */ + public function setLibrary($library) { + $this->library = $library; + return $this; + } + + /** + * Gets the icon path for this layout definition. + * + * @return string|null + * The icon path, if it exists. + */ + public function getIconPath() { + return $this->icon; + } + + /** + * Sets the icon path for this layout definition. + * + * @param string|null $icon + * The icon path. + * + * @return $this + */ + public function setIconPath($icon) { + $this->icon = $icon; + return $this; + } + + /** + * Gets the regions for this layout definition. + * + * @return array[] + * The layout regions. The keys of the array are the machine names of the + * regions, and the values are an associative array with the following + * keys: + * - label: (string) The human-readable name of the region. + * Any remaining keys may have special meaning for the given layout plugin, + * but are undefined here. + */ + public function getRegions() { + return $this->regions; + } + + /** + * Sets the regions for this layout definition. + * + * @param array[] $regions + * An array of regions, see ::getRegions() for the format. + * + * @return $this + */ + public function setRegions(array $regions) { + $this->regions = $regions; + return $this; + } + + /** + * Gets the machine-readable region names. + * + * @return string[] + * An array of machine-readable region names. + */ + public function getRegionNames() { + return array_keys($this->getRegions()); + } + + /** + * Gets the human-readable region labels. + * + * @return string[] + * An array of human-readable region labels. + */ + public function getRegionLabels() { + $regions = $this->getRegions(); + return array_combine(array_keys($regions), array_column($regions, 'label')); + } + + /** + * Gets the default region. + * + * @return string + * The machine-readable name of the default region. + */ + public function getDefaultRegion() { + return $this->default_region; + } + + /** + * Sets the default region. + * + * @param string $default_region + * The machine-readable name of the default region. + * + * @return $this + */ + public function setDefaultRegion($default_region) { + $this->default_region = $default_region; + return $this; + } + + /** + * Gets the name of the provider of this layout definition. + * + * @return string + * The name of the provider of this layout definition. + */ + public function getProvider() { + return $this->provider; + } + + /** + * Gets the config dependencies of this layout definition. + * + * @return array + * An array of config dependencies. + * + * @see \Drupal\Core\Plugin\PluginDependencyTrait::calculatePluginDependencies() + */ + public function getConfigDependencies() { + return $this->config_dependencies; + } + + /** + * Sets the config dependencies of this layout definition. + * + * @param array $config_dependencies + * An array of config dependencies. + * + * @return $this + */ + public function setConfigDependencies(array $config_dependencies) { + $this->config_dependencies = $config_dependencies; + return $this; + } + + /** + * {@inheritdoc} + */ + public function getDeriver() { + return $this->deriver; + } + + /** + * {@inheritdoc} + */ + public function setDeriver($deriver) { + $this->deriver = $deriver; + return $this; + } + +} diff --git a/core/lib/Drupal/Core/Layout/LayoutInterface.php b/core/lib/Drupal/Core/Layout/LayoutInterface.php new file mode 100644 index 0000000..32ee74d --- /dev/null +++ b/core/lib/Drupal/Core/Layout/LayoutInterface.php @@ -0,0 +1,33 @@ +themeHandler = $theme_handler; + + $this->setCacheBackend($cache_backend, 'layout'); + $this->alterInfo('layout'); + } + + /** + * {@inheritdoc} + */ + protected function providerExists($provider) { + return $this->moduleHandler->moduleExists($provider) || $this->themeHandler->themeExists($provider); + } + + /** + * {@inheritdoc} + */ + protected function getDiscovery() { + if (!$this->discovery) { + $discovery = new AnnotatedClassDiscovery($this->subdir, $this->namespaces, $this->pluginDefinitionAnnotationName, $this->additionalAnnotationNamespaces); + $discovery = new YamlDiscoveryDecorator($discovery, 'layouts', $this->moduleHandler->getModuleDirectories() + $this->themeHandler->getThemeDirectories()); + $discovery = new ObjectDefinitionDiscoveryDecorator($discovery, $this->pluginDefinitionAnnotationName); + $discovery = new ObjectDefinitionContainerDerivativeDiscoveryDecorator($discovery); + $this->discovery = $discovery; + } + return $this->discovery; + } + + /** + * {@inheritdoc} + */ + public function processDefinition(&$definition, $plugin_id) { + parent::processDefinition($definition, $plugin_id); + + if (!$definition instanceof LayoutDefinition) { + throw new InvalidPluginDefinitionException($plugin_id, sprintf('The "%s" layout definition must extend %s', $plugin_id, LayoutDefinition::class)); + } + + // Keep class definitions standard with no leading slash. + // @todo Remove this once https://www.drupal.org/node/2824655 is resolved. + $definition->setClass(ltrim($definition->getClass(), '\\')); + + // Add the module or theme path to the 'path'. + $provider = $definition->getProvider(); + if ($this->moduleHandler->moduleExists($provider)) { + $base_path = $this->moduleHandler->getModule($provider)->getPath(); + } + elseif ($this->themeHandler->themeExists($provider)) { + $base_path = $this->themeHandler->getTheme($provider)->getPath(); + } + else { + $base_path = ''; + } + + $path = $definition->getPath(); + $path = !empty($path) ? $base_path . '/' . $path : $base_path; + $definition->setPath($path); + + // Add the base path to the icon path. + if ($icon_path = $definition->getIconPath()) { + $definition->setIconPath($path . '/' . $icon_path); + } + + // Add a dependency on the provider of the library. + if ($library = $definition->getLibrary()) { + $config_dependencies = $definition->getConfigDependencies(); + list($library_provider) = explode('/', $library, 2); + if ($this->moduleHandler->moduleExists($library_provider)) { + $config_dependencies['module'][] = $library_provider; + } + elseif ($this->themeHandler->themeExists($library_provider)) { + $config_dependencies['theme'][] = $library_provider; + } + $definition->setConfigDependencies($config_dependencies); + } + + // If 'template' is set, then we'll derive 'template_path' and 'theme_hook'. + $template = $definition->getTemplate(); + if (!empty($template)) { + $template_parts = explode('/', $template); + + $template = array_pop($template_parts); + $template_path = $path; + if (count($template_parts) > 0) { + $template_path .= '/' . implode('/', $template_parts); + } + $definition->setTemplate($template); + $definition->setThemeHook(strtr($template, '-', '_')); + $definition->setTemplatePath($template_path); + } + + if (!$definition->getDefaultRegion()) { + $definition->setDefaultRegion(key($definition->getRegions())); + } + } + + /** + * {@inheritdoc} + */ + public function getThemeImplementations() { + $hooks = []; + /** @var \Drupal\Core\Layout\LayoutDefinition[] $definitions */ + $definitions = $this->getDefinitions(); + foreach ($definitions as $definition) { + if ($template = $definition->getTemplate()) { + $hooks[$definition->getThemeHook()] = [ + 'variables' => [ + 'content' => [], + 'settings' => [], + 'layout' => [], + ], + 'template' => $template, + 'path' => $definition->getTemplatePath(), + ]; + } + } + return $hooks; + } + + /** + * {@inheritdoc} + */ + public function getCategories() { + // Fetch all categories from definitions and remove duplicates. + $categories = array_unique(array_values(array_map(function (LayoutDefinition $definition) { + return $definition->getCategory(); + }, $this->getDefinitions()))); + natcasesort($categories); + return $categories; + } + + /** + * {@inheritdoc} + * + * @return \Drupal\Core\Layout\LayoutDefinition[] + */ + public function getSortedDefinitions(array $definitions = NULL, $label_key = 'label') { + // Sort the plugins first by category, then by label. + $definitions = isset($definitions) ? $definitions : $this->getDefinitions(); + // Suppress errors because PHPUnit will indirectly modify the contents, + // triggering https://bugs.php.net/bug.php?id=50688. + @uasort($definitions, function (LayoutDefinition $a, LayoutDefinition $b) { + if ($a->getCategory() != $b->getCategory()) { + return strnatcasecmp($a->getCategory(), $b->getCategory()); + } + return strnatcasecmp($a->getLabel(), $b->getLabel()); + }); + return $definitions; + } + + /** + * {@inheritdoc} + * + * @return \Drupal\Core\Layout\LayoutDefinition[][] + */ + public function getGroupedDefinitions(array $definitions = NULL, $label_key = 'label') { + $definitions = $this->getSortedDefinitions(isset($definitions) ? $definitions : $this->getDefinitions(), $label_key); + $grouped_definitions = []; + foreach ($definitions as $id => $definition) { + $grouped_definitions[(string) $definition->getCategory()][$id] = $definition; + } + return $grouped_definitions; + } + + /** + * {@inheritdoc} + */ + public function getLayoutOptions() { + $layout_options = []; + foreach ($this->getGroupedDefinitions() as $category => $layout_definitions) { + foreach ($layout_definitions as $name => $layout_definition) { + $layout_options[$category][$name] = $layout_definition->getLabel(); + } + } + return $layout_options; + } + +} diff --git a/core/lib/Drupal/Core/Layout/LayoutPluginManagerInterface.php b/core/lib/Drupal/Core/Layout/LayoutPluginManagerInterface.php new file mode 100644 index 0000000..c0e606d --- /dev/null +++ b/core/lib/Drupal/Core/Layout/LayoutPluginManagerInterface.php @@ -0,0 +1,65 @@ +getDeriver()) { + if (!class_exists($class)) { + throw new InvalidDeriverException(sprintf('Plugin (%s) deriver "%s" does not exist.', $base_definition['id'], $class)); + } + if (!is_subclass_of($class, '\Drupal\Component\Plugin\Derivative\DeriverInterface')) { + throw new InvalidDeriverException(sprintf('Plugin (%s) deriver "%s" must implement \Drupal\Component\Plugin\Derivative\DeriverInterface.', $base_definition['id'], $class)); + } + } + return $class; + } + +} diff --git a/core/lib/Drupal/Core/Layout/ObjectDefinitionDiscoveryDecorator.php b/core/lib/Drupal/Core/Layout/ObjectDefinitionDiscoveryDecorator.php new file mode 100644 index 0000000..ef98393 --- /dev/null +++ b/core/lib/Drupal/Core/Layout/ObjectDefinitionDiscoveryDecorator.php @@ -0,0 +1,76 @@ +decorated = $decorated; + $this->pluginDefinitionAnnotationName = $plugin_definition_annotation_name; + } + + /** + * {@inheritdoc} + */ + public function getDefinitions() { + $definitions = $this->decorated->getDefinitions(); + foreach ($definitions as $id => $definition) { + if (is_array($definition)) { + $definitions[$id] = (new $this->pluginDefinitionAnnotationName($definition))->get(); + } + } + return $definitions; + } + + /** + * Passes through all unknown calls onto the decorated object. + * + * @param string $method + * The method to call on the decorated plugin discovery. + * @param array $args + * The arguments to send to the method. + * + * @return mixed + * The method result. + */ + public function __call($method, $args) { + return call_user_func_array([$this->decorated, $method], $args); + } + +} diff --git a/core/modules/system/system.module b/core/modules/system/system.module index 3c858f9..753ecfd 100644 --- a/core/modules/system/system.module +++ b/core/modules/system/system.module @@ -161,7 +161,23 @@ function system_help($route_name, RouteMatchInterface $route_match) { * Implements hook_theme(). */ function system_theme() { - return array_merge(drupal_common_theme(), array( + return array_merge( + drupal_common_theme(), + _system_theme(), + \Drupal::service('plugin.manager.layout')->getThemeImplementations() + ); +} + +/** + * Provides theme implementations for system module. + * + * @see system_theme() + * + * @return array + * See return signature of hook_theme(). + */ +function _system_theme() { + return array( // Normally theme suggestion templates are only picked up when they are in // themes. We explicitly define theme suggestions here so that the block // templates in core/modules/system/templates are picked up. @@ -224,7 +240,7 @@ function system_theme() { ), 'template' => 'entity-add-list', ), - )); + ); } /** diff --git a/core/modules/system/tests/modules/layout_test/config/schema/layout_test.schema.yml b/core/modules/system/tests/modules/layout_test/config/schema/layout_test.schema.yml new file mode 100644 index 0000000..521faaf --- /dev/null +++ b/core/modules/system/tests/modules/layout_test/config/schema/layout_test.schema.yml @@ -0,0 +1,7 @@ +layout_plugin.settings.layout_test_plugin: + type: layout_plugin.settings + label: 'Layout test plugin settings' + mapping: + setting_1: + type: string + label: 'Setting 1' diff --git a/core/modules/system/tests/modules/layout_test/css/layout-test-2col.css b/core/modules/system/tests/modules/layout_test/css/layout-test-2col.css new file mode 100644 index 0000000..d5c05b9 --- /dev/null +++ b/core/modules/system/tests/modules/layout_test/css/layout-test-2col.css @@ -0,0 +1,16 @@ + +.layout-example-2col .region-left { + float: left; + width: 50%; +} +* html .layout-example-2col .region-left { + width: 49.9%; +} + +.layout-example-2col .region-right { + float: left; + width: 50%; +} +* html .layout-example-2col .region-right { + width: 49.9%; +} diff --git a/core/modules/system/tests/modules/layout_test/layout_test.info.yml b/core/modules/system/tests/modules/layout_test/layout_test.info.yml new file mode 100644 index 0000000..bfed2a5 --- /dev/null +++ b/core/modules/system/tests/modules/layout_test/layout_test.info.yml @@ -0,0 +1,6 @@ +name: 'Layout test' +type: module +description: 'Support module for testing layouts.' +package: Testing +version: VERSION +core: 8.x diff --git a/core/modules/system/tests/modules/layout_test/layout_test.layouts.yml b/core/modules/system/tests/modules/layout_test/layout_test.layouts.yml new file mode 100644 index 0000000..3b58561 --- /dev/null +++ b/core/modules/system/tests/modules/layout_test/layout_test.layouts.yml @@ -0,0 +1,20 @@ +layout_test_1col: + label: 1 column layout + category: Layout test + template: templates/layout-test-1col + regions: + top: + label: Top region + bottom: + label: Bottom region + +layout_test_2col: + label: 2 column layout + category: Layout test + template: templates/layout-test-2col + library: layout_test/layout_test_2col + regions: + left: + label: Left region + right: + label: Right region diff --git a/core/modules/system/tests/modules/layout_test/layout_test.libraries.yml b/core/modules/system/tests/modules/layout_test/layout_test.libraries.yml new file mode 100644 index 0000000..a696c0f --- /dev/null +++ b/core/modules/system/tests/modules/layout_test/layout_test.libraries.yml @@ -0,0 +1,5 @@ +layout_test_2col: + version: 1.x + css: + theme: + css/layout-test-2col.css: {} diff --git a/core/modules/system/tests/modules/layout_test/src/Plugin/Layout/LayoutTestPlugin.php b/core/modules/system/tests/modules/layout_test/src/Plugin/Layout/LayoutTestPlugin.php new file mode 100644 index 0000000..e678bfa --- /dev/null +++ b/core/modules/system/tests/modules/layout_test/src/Plugin/Layout/LayoutTestPlugin.php @@ -0,0 +1,61 @@ + 'Default', + ]; + } + + /** + * {@inheritdoc} + */ + public function buildConfigurationForm(array $form, FormStateInterface $form_state) { + $form['setting_1'] = [ + '#type' => 'textfield', + '#title' => 'Blah', + '#default_value' => $this->configuration['setting_1'], + ]; + return $form; + } + + /** + * {@inheritdoc} + */ + public function validateConfigurationForm(array &$form, FormStateInterface $form_state) { + } + + /** + * {@inheritdoc} + */ + public function submitConfigurationForm(array &$form, FormStateInterface $form_state) { + $this->configuration['setting_1'] = $form_state->getValue('setting_1'); + } + +} diff --git a/core/modules/system/tests/modules/layout_test/templates/layout-test-1col.html.twig b/core/modules/system/tests/modules/layout_test/templates/layout-test-1col.html.twig new file mode 100644 index 0000000..e7a7eb5 --- /dev/null +++ b/core/modules/system/tests/modules/layout_test/templates/layout-test-1col.html.twig @@ -0,0 +1,14 @@ +{# +/** + * @file + * Template for an example 1 column layout. + */ +#} +