This is a working prototype, although I will be rewritting it a bit later.
<?php
/**
* @file
* Contains \Drupal\mymodule\Form\ThemeTemplateForm.
*/
namespace Drupal\mymodule\Form;
use Drupal\Core\Form\FormBase;
use Drupal\Core\Theme\Registry;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Lock\LockBackendInterface;
use Drupal\Component\Utility\String;
/**
* Form for editing template files for current theme.
*/
class ThemeTemplateForm extends FormBase {
/**
* The cache backend to use for the complete theme registry data.
*
* @var \Drupal\Core\Cache\CacheBackendInterface
*/
protected $cache;
/**
* The lock backend that should be used.
*
* @var \Drupal\Core\Lock\LockBackendInterface
*/
protected $lock;
/**
* The module handler to use to load modules.
*
* @var \Drupal\Core\Extension\ModuleHandlerInterface
*/
protected $moduleHandler;
/**
* The theme registry service.
*
* @var \Drupal\Core\Theme\Registry
*/
protected $registry;
/**
* The template that is being edited/created.
*
* @var string
*/
protected $template;
/**
* The front-end theme that we'll be editing templates for.
*
* @var string
*/
protected $theme;
/**
* Creates ThemeTemplateForm object.
*
* @param \Drupal\Core\Cache\CacheBackendInterface $cache
* The cache backend interface to use for the complete theme registry data.
* @param \Drupal\Core\Lock\LockBackendInterface $lock
* The lock backend.
* @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
* The module handler to use to load modules.
*/
public function __construct(CacheBackendInterface $cache, LockBackendInterface $lock, ModuleHandlerInterface $module_handler) {
$this->cache = $cache;
$this->lock = $lock;
$this->moduleHandler = $module_handler;
$this->theme = $this->config('system.theme')->get('default');
$this->registry = new Registry($this->cache, $this->lock, $this->moduleHandler, $this->theme);
$template = $this->getRequest()->query->get('template');
if (!empty($template) && $this->templateIsAllowed($template)) {
// Always use machine name of the template
$this->template = str_replace('-', '_', $template);
}
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('cache.default'),
$container->get('lock'),
$container->get('module_handler')
);
}
/**
* Returns path to selected theme.
*
* @return string
* Path relative to Drupal root.
*/
public function themePath() {
return drupal_get_path('theme', $this->theme);
}
/**
* Returns the list of template files.
*/
public function templateList() {
$list = array_filter($this->registry->get(), function($info) {
return isset($info['template']);
});
// Allow modules to alter the list
\Drupal::moduleHandler()->alter('editable_template_list', $list);
ksort($list);
return $list;
}
/**
* Returns list of overridden templates by the front-end theme.
*/
public function overriddenTemplateList() {
$list = array_filter($this->templateList(), function($info) {
// The system will return templates that have preprocess functions
// implemented by the theme so we need to remove them from the list
// since they aren't really overridden.
$theme_path = $this->themePath();
return $info['theme path'] == $theme_path && (isset($info['path']) && stripos($info['path'], $theme_path) === 0);
});
return $list;
}
/**
* See if provided template is allowed.
*
* @param string $template
* Template name.
*
* @return bool
* TRUE if template is allowed, FALSE otherwise.
*/
public function templateIsAllowed($template) {
$list = $this->templateList();
$list[$template] = array();
\Drupal::moduleHandler()->alter('editable_template_list', $list);
// If no module removed the template the template is allowed
return isset($list[$template]);
}
/**
* Returns information if the provided template is overridden by the theme.
*
* @param string $template
* The template name.
*
* @return bool
* Returns TRUE if the template is overridden, FALSE otherwise.
*/
public function templateIsOverridden($template) {
$overridden = $this->overriddenTemplateList();
return isset($overridden[$template]);
}
/**
* Returns the template information.
*
* @param string $template
* The template name.
*
* @return array
* Returns the template information array. Empty array is returned
* when template was not found.
*/
public function templateInfo($template) {
$templates = $this->templateList();
if (isset($templates[$template])) {
return $templates[$template];
} else {
$base_hook = $this->registry->getBaseHook($template);
return $base_hook ? $template[$base_hook] : array();
}
}
/**
* Returns the source path of the template file.
*
* @param string $template
* Template name.
*
* @return string
* Path to the source template file relative to Drupal root.
*/
public function getTemplateSourcePath($template) {
$info = $this->templateInfo($template);
return $info['template'] . '.html.twig';
}
/**
* Returns the path of the template in the theme.
* If the template does not exist(is not overridden yet)
* the 'templates' directory within the theme directory will
* be used as destination for the new template. Otherwise
* 'path' will be used since themes don't have to explicitly put
* their templates into the 'templates'directory.
*
* @param string $template
* Template name.
*
* @return string
* The path to the template file within the theme directory
* relative to Drupal root.
*/
public function getTemplateThemePath($template) {
$info = $this->templateInfo($template);
if (isset($info['path']) && stripos($info['path'], $this->themePath()) === 0) {
return $info['path'] . '/' . str_replace('_', '-', $template) . '.html.twig';
} else{
return $this->themePath() . '/templates/' . str_replace('_', '-', $template) . '.html.twig';
}
}
/**
* See if provided path is allowed for writing/overriding
* a template file.
*
* @param string $path
* Path to the template relative to Drupal root.
*
* @return bool
* TRUE if template can be created/overridden, FALSE otherwise.
*/
public function pathIsAllowed($path) {
// Access is allowed only to theme's directory AND this
// always has to be located within the 'sites' directory.
return stripos($path, $this->themePath()) === 0 && stripos($path, 'sites/') === 0;
}
/**
* Returns content of requested template.
* If the template is not implemented by the theme
* the content of the original template will be returned.
*
* @param string $template
* Template name.
* @return string|false
* Returns the code of the template or FALSE if template could not be loaded.
*/
public function getTemplateCode($template) {
$info = $this->templateInfo($template);
if (empty($info)) {
return FALSE;
}
$path = isset($info['path']) ? $info['path'] . '/' . $template : $info['template'];
$path .= '.html.twig';
return @file_get_contents('.' . base_path() . '/' . $path);
}
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'theme_template_form';
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, array &$form_state) {
$templates = $this->templateList();
$overridden = $this->overriddenTemplateList();
if ($this->template) {
$form['#title'] = $this->t('Template: @template', array('@template' => str_replace('_', '-', $this->template)));
} else {
$form['#title'] = $this->t('Create new template');
}
$form['left'] = array(
'#type' => 'container',
'#attributes' => array(
'id' => 'template-edit-left'
)
);
$form['left']['edit'] = array(
'#type' => 'textarea',
'#rows' => 35,
'#default_value' => $this->template ? $this->getTemplateCode($this->template) : NULL,
'#required' => TRUE
);
$form['left']['name_wrapper'] = array(
'#type' => 'fieldset',
'#title' => $this->t('Template name'),
'#collapsible' => TRUE,
'#collapsed' => TRUE
);
$form['left']['name_wrapper']['name'] = array(
'#type' => 'textfield',
'#title' => $this->t('Template name'),
'#required' => !$this->template,
'#field_prefix' => $this->template ? String::checkPlain(str_replace('_', '-', $this->template)) . '--' : NULL
);
$form['left']['actions'] = array(
'#type' => 'actions',
'save' => array(
'#type' => 'submit',
'#name' => 'save',
'#value' => $this->t('Save')
),
'delete' => array(
'#type' => 'submit',
'#name' => 'delete',
'#value' => $this->t('Delete'),
'#access' => $this->template && $this->templateIsOverridden($this->template)
)
);
$form['right'] = array(
'#type' => 'container',
'#attributes' => array(
'id' => 'template-edit-right'
),
'templates' => array(
'#theme' => 'item_list',
'#items' => array()
)
);
foreach ($overridden AS $template => $info) {
$form['right']['templates']['#items'][$template] = array(
'#type' => 'link',
'#title' => str_replace('_', '-', $template),
'#route_name' => 'mymodule.settings.theme.template',
'#options' => array(
'query' => array(
'template' => str_replace('_', '-', $template)
)
),
'#attributes' => array(
'class' => array('overridden')
)
);
}
foreach ($templates AS $template => $info) {
if (isset($form['right']['templates']['#items'][$template])) {
continue;
}
$form['right']['templates']['#items'][$template] = array(
'#type' => 'link',
'#title' => str_replace('_', '-', $template),
'#route_name' => 'mymodule.settings.theme.template',
'#options' => array(
'query' => array(
'template' => str_replace('_', '-', $template)
)
)
);
}
if ($this->template && isset($form['right']['templates']['#items'][$this->template])) {
$form['right']['templates']['#items'][$this->template]['#attributes']['class'][] = 'active';
}
$form['#attached'] = array(
'css' => array(
drupal_get_path('module', 'mymodule') . '/css/mymodule.themetemplateform.css.less'
),
'library' => array(
'mymodule/codemirror.twig'
),
'js' => array(
drupal_get_path('module', 'mymodule') . '/js/mymodule.themetemplateform.js'
)
);
return $form;
}
/**
* {@inheritdoc}
*/
public function validateForm(array &$form, array &$form_state) {
// If there is no template, the 'name' is required so we'll always end up with a value
// for the name.
$template = $this->template ? $this->template : '';
if ((string) $form_state['values']['name'] !== '') {
$template = (string) $template === '' ? $form_state['values']['name'] : $template . '__' . $form_state['values']['name'];
}
$template = str_replace('-', '_', $template);
if (!$this->templateIsAllowed($template)) {
$this->setFormError('name', $form_state, $this->t("Template name is not allowed."));
} elseif (!$this->pathIsAllowed($this->getTemplateThemePath($template))) {
$this->setFormError('name', $form_state, $this->t("Template cannot be changed."));
}
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, array &$form_state) {
// If there is no template, the 'name' is required so we'll always end up with a value
// for the name.
$template = $this->template ? $this->template : '';
if ((string) $form_state['values']['name'] !== '') {
$template = (string) $template === '' ? $form_state['values']['name'] : $template . '__' . $form_state['values']['name'];
}
$template = str_replace('-', '_', $template);
$tplpath = $this->getTemplateThemePath($template);
switch ($form_state['triggering_element']['#name']) {
case 'save':
$write = @file_put_contents('.' . base_path() . $tplpath, $form_state['values']['edit']);
if ($write === FALSE) {
$this->setFormError('edit', $form_state, $this->t("Couldn't write data into file. Please try again or contact help desk."));
} else {
$form_state['redirect_route'] = array(
'route_name' => 'mymodule.settings.theme.template',
'options' => array(
'query' => array(
'template' => str_replace('_', '-', $template)
)
)
);
mymodule_success_message();
}
break;
case 'delete':
$delete = @unlink('.' . base_path() . $tplpath);
if ($delete) {
$form_state['redirect_route'] = array(
'route_name' => 'mymodule.settings.theme.template'
);
mymodule_success_message();
} else {
$this->setFormError('edit', $form_state, $this->t("Couldn't delete template. File does not exist or is not accessible."));
}
break;
}
// Always rebuild cache
Cache::invalidateTags(array('theme_registry' => TRUE));
}
}