ElkArte Community

Elk Development => Feature Discussion => Topic started by: Joshua Dickerson on December 11, 2013, 07:43:13 am

Title: Theme/template class
Post by: Joshua Dickerson on December 11, 2013, 07:43:13 am
I was bored about an hour ago while I was checking back on Elkarte. So I took about 45 minutes to write out a stop-gap for themes (I would prefer a better solution in the long run) which makes changing subtemplates easier IMO.

Code: [Select]
<?php

/**
 * A template comprised of subtemplates
 * @todo include methods like javascript_escape()
 */
class Template
{
public $context;
public $settings;
public $scripurl;
public $txt;
public $user_info;
public $options;

// @todo a container should be passed in here containing the Theme class instead of having Theme as static
public function __construct()
{
// References in case they change
// Using properties instead of globals allows the theme author to change them without affecting all templates
$this->context = &$GLOBALS['context'];
$this->settings = &$GLOBALS['settings'];
$this->scripurl = &$GLOBALS['scripurl'];
$this->txt = &$GLOBALS['txt'];
$this->user_info = &$GLOBALS['user_info'];
$this->options = &$GLOBALS['options'];
}

/**
* Shortcut function to register a subtemplate as a global subtemplate
* @param string $name
*/
public function register_subtemplate($name)
{
return Theme::getInstance()->register_global_subtemplate($name, array($this, $name));
}

/**
* Shortcut function to subtemplate
*/
public function subtemplate($subtemplate, $template = '*')
{
return call_user_func_array(array(Theme::getInstance(), 'subtemplate'), func_get_args());
}

/**
* Shortcut to get a global subtemplate
* @param string $name The subtemplate alias
* @param array $args Arguments
* @return mixed
* @throws Exception when the global subtemplate cannot be found
*/
public function __call($name, $args)
{
return call_user_func_array(array(Theme::getInstance(), 'global_subtemplate'), array_unshift($args, $name));
}
}
Code: [Select]
<?php
/**
 * The theme class which holds all of the instances of the templates
 * @todo This would be even better if it used lambda functions for registration so that not all of the templates need to be instantiated at the same time
 *
 * Not shown here is all that is required to make this a singleton.
 * If there was a container class it wouldn't need to be, but that's the way it is setup now
 */
class Theme
{
/*
* Template classes
*/
protected $templates = array();
/**
* The global subtemplates
*/
protected $subtemplates = array();

/**
* @param string $alias What it will be referred to as
* @param callable $class An instance of the class
*/
public function register_template($alias, Template $class)
{
$this->templates[$alias] = $class;
}

/**
* Get all of the subtemplates of a template
* @param string $name Template alias
* @return array
*/
public function get_subtemplates($name)
{
if ($name === '*')
return $this->subtemplates;

// The template must be registered first
if (!isset($this->templates[$name]))
throw new Exception('Template "' . $name . '" not registered');

// If the template has a method for this, use that
if (is_callable($this->templates[$name]->get_subtemplates()))
return $this->templates[$name]->get_subtemplates();
else
return get_class_methods($this->templates[$name]);
}

public function get_global_subtemplates()
{
return $this->subtemplates;
}

/**
* Get an instance of the template
* @param string $class The class alias
* @return Template|false
*/
public function template($class)
{
if ($class === '*')
return false;

return isset($this->templates[$class]) ? $this->templates[$class] : false;
}

/**
* Call a subtemplate
* @param string $subtemplate The subtemplate alias/name
* @param string $template = '*' The template or * for global subtemplates
* @return mixed
*/
public function subtemplate($subtemplate, $template = '*')
{
$args = func_get_args();
$subtemplate = array_shift($args);
$template = array_shift($args);

// A global subtemplate request
if ($template === '*')
{
if (!isset($this->subtemplates[$subtemplate]))
throw new Exception('Global subtemplate "' . $subtemplate . '" not registered');

return call_user_func_array($this->subtemplates[$subtemplate], $args);
}

// The template must be registered first
if (!isset($this->templates[$template]))
throw new Exception('Template "' . $template . '" not registered');

return call_user_func_array(array($this->templates[$template], $subtemplate), $args);
}

/**
* Calls subtemplate with the global template
* @param string $subtemplate
* @return mixed
*/
public function global_subtemplate($subtemplate)
{
$args = func_get_args();
$subtemplate = array_shift($args);

if (!isset($this->subtemplates[$subtemplate]))
throw new Exception('Global subtemplate "' . $subtemplate . '" not registered');

return call_user_func_array($this->subtemplates[$subtemplate], $args);
}

/**
* Registers a global subtemplate
* @param string $name
* @param callable $callable The method callback
*/
public function register_global_subtemplate($name, $callable)
{
if (!is_callable($callable))
throw new Exception('Global subtemplate must be callable');

$this->subtemplates[$name] = $callable;
}
}
// /default/Memberlist.template.php
Code: [Select]
<?php
class Template_Memberlist extends Template
{
public function pages_and_buttons_above()
{
$extra = '
<form id="mlsearch" action="' . $this->scripturl . '?action=memberlist;sa=search" method="post" accept-charset="UTF-8">
<ul class="floatright">
<li>
<input id="mlsearch_input" onfocus="toggle_mlsearch_opt();" type="text" name="search" value="" class="input_text" placeholder="' . $this->txt['search'] . '" />&nbsp;
<input type="submit" name="search2" value="' . $this->txt['search'] . '" class="button_submit" />
<ul id="mlsearch_options">';

foreach ($this->context['search_fields'] as $id => $title)
{
$extra .= '
<li class="mlsearch_option">
<label for="fields-' . $id . '"><input type="checkbox" name="fields[]" id="fields-' . $id . '" value="' . $id . '" ' . (in_array($id, $this->context['search_defaults']) ? 'checked="checked"' : '') . ' class="input_check floatright" />' . $title . '</label>
</li>';
}

$extra .= '
</ul>
</li>
</ul>
</form>';
/**
 * !!! Notice that here I use $this->subtemplate('pagesection'), but I could use $this->pagesection()
 * !!! because I am assuming pagesection() would be registered as a global subtemplate
 */
$this->subtemplate('pagesection', 'memberlist_buttons', 'right', array('extra' => $extra));

echo '
<script><!-- // --><![CDATA[
function toggle_mlsearch_opt()
{
$(\'body\').on(\'click\', mlsearch_opt_hide);
$(\'#mlsearch_options\').slideToggle(\'fast\');
}

function mlsearch_opt_hide(ev)
{
if (ev.target.id === \'mlsearch_options\' || ev.target.id === \'mlsearch_input\')
return;

$(\'body\').off(\'click\', mlsearch_opt_hide);
$(\'#mlsearch_options\').slideToggle(\'fast\');
}
// ]]></script>';

}

/**
* Displays a sortable listing of all members registered on the forum.
*/
public function memberlist()
{
echo '
<div id="memberlist">
<h2 class="category_header">
<span class="floatleft">', $this->txt['members_list'], '</span>';

if (!isset($this->context['old_search']))
echo '
<span class="floatright" letter_links>', $this->context['letter_links'], '</span>';

echo '
</h2>
<table class="table_grid">
<thead>
<tr class="table_head">';

// Display each of the column headers of the table.
foreach ($this->context['columns'] as $key => $column)
{
// This is a selected column, so underline it or some such.
if ($column['selected'])
echo '
<th scope="col"', isset($column['class']) ? ' class="' . $column['class'] . '"' : '', ' style="width: auto; white-space: nowrap"' . (isset($column['colspan']) ? ' colspan="' . $column['colspan'] . '"' : '') . '>
<a href="' . $column['href'] . '" rel="nofollow">' . $column['label'] . '</a><img class="sort" src="' . $this->settings['images_url'] . '/sort_' . $this->context['sort_direction'] . '.png" alt="" />
</th>';
// This is just some column... show the link and be done with it.
else
echo '
<th scope="col" ', isset($column['class']) ? ' class="' . $column['class'] . '"' : '', isset($column['width']) ? ' style="width:' . $column['width'] . '"' : '', isset($column['colspan']) ? ' colspan="' . $column['colspan'] . '"' : '', '>
', $column['link'], '
</th>';
}

echo '
</tr>
</thead>
<tbody>';

// Assuming there are members loop through each one displaying their data.
$alternate = true;
if (!empty($this->context['members']))
{
foreach ($this->context['members'] as $member)
{
echo '
<tr class="', $alternate ? 'alternate_' : 'standard_', 'row"', empty($member['sort_letter']) ? '' : ' id="letter' . $member['sort_letter'] . '"', '>';

foreach ($this->context['columns'] as $column => $values)
{
if (isset($member[$column]))
{
if ($column == 'online')
{
echo '
<td>
', $this->context['can_send_pm'] ? '<a href="' . $member['online']['href'] . '" title="' . $member['online']['text'] . '">' : '', $this->settings['use_image_buttons'] ? '<img src="' . $member['online']['image_href'] . '" alt="' . $member['online']['text'] . '" class="centericon" />' : $member['online']['label'], $this->context['can_send_pm'] ? '</a>' : '', '
</td>';
continue;
}
elseif ($column == 'email_address')
{
echo '
<td>', $member['show_email'] == 'no' ? '' : '<a href="' . $this->scripturl . '?action=emailuser;sa=email;uid=' . $member['id'] . '" rel="nofollow"><img src="' . $this->settings['images_url'] . '/profile/email_sm.png" alt="' . $this->txt['email'] . '" title="' . $this->txt['email'] . ' ' . $member['name'] . '" /></a>', '</td>';
continue;
}
else
echo '
<td>', $member[$column], '</td>';
}
// Any custom fields on display?
elseif (!empty($this->context['custom_profile_fields']['columns']) && isset($this->context['custom_profile_fields']['columns'][$column]))
{
echo '
<td>', $member['options'][substr($column, 5)], '</td>';
}
}

echo '
</tr>';

$alternate = !$alternate;
}
}
// No members?
else
echo '
<tr>
<td colspan="', $this->context['colspan'], '" class="standard_row">', $this->txt['search_no_results'], '</td>
</tr>';

echo '
</tbody>
</table>';
}

public function pages_and_buttons_below()
{
// If it is displaying the result of a search show a "search again" link to edit their criteria.
if (isset($this->context['old_search']))
$extra = '
<a class="linkbutton_right" href="' . $this->scripturl . '?action=memberlist;sa=search;search=' . $this->context['old_search_value'] . '">' . $this->txt['mlist_search_again'] . '</a>';
else
$extra = '';

// Show the page numbers again. (makes 'em easier to find!)
$this->pagesection(false, false, array('extra' => $extra));

echo '
</div>';
}
}

Say I want to change Template_Memberlist::pages_and_buttons_above() with Template_Josh_Memberlist::pages_and_buttons_above() in Josh_Theme. I would simply extend Template_Memberlist and overload pages_and_buttons_above(). Obviously if there was an autoloader, I wouldn't need to include that file prior to doing that. If I want to change an entire template, but not change anything else in a theme, I can just overwrite the registration for that template with a single call. There's a lot of opportunities in this for hooks, but I didn't add them because most people would just overload the subtemplate if they wanted to do a hook. It would be nice to have type hinting but I handled what I could with exceptions.

I wrote this out in about 40 minutes in Notepad++ and I definitely didn't test it. I am just throwing the idea out there.
Title: Re: Theme/template class
Post by: Joshua Dickerson on December 11, 2013, 10:31:34 am
Wasting too much time on this :(

The Theme class would contain loadTheme() (rename to load()) and all of its associated functions. Wherever possible, like template_dirs, use properties instead of global variables. Maybe if necessary use both.

Template_Layers should be rewritten to extend ArrayObject and then this will all work together more nicely.

This forum is setup to only allow 20k chars in a post. Then I tried to upload the php file but it won't let me :(
Title: Re: Theme/template class
Post by: emanuele on December 23, 2013, 08:14:39 am
https://github.com/elkarte/Elkarte/issues/489#issuecomment-31118417

Will open a new one for tracking "later" (hopefully before Christmas lol).
Title: Re: Theme/template class
Post by: Joshua Dickerson on February 18, 2014, 02:26:38 am
Went a little farther with it. I am kind of scatter-brained so I jump from one thing to another. I didn't do this in a branch because I wasn't sure where I wanted to go and it's pretty much a total rewrite of the theme/template/view system anyway. Actually, it is the first implementation of a separation of concerns between the view and the templates. Right now the theme is the view (or vice versa).

Once this is done, Load.php and Subs.php will be way shorter. Load.php will probably be deprecated with a little more work. That's okay though, because then we need to add a Services.php will defines all of the services in the DIC. A separate Providers directory with a provider class for each controller/manager and they each define their own services would be good, but I don't know about that.

Some ideas:

Have an assets directory with js/css/images. Uploads can also go in there - maybe.

For uploads, have two directories - one protected and one unprotected. The unprotected checks if guests can see it, if so it can be served without any rewrite rules or PHP. For the protected stuff, it can go down a level and then you can only access it with PHP. If you want to be able to track the downloads, hopefully all of these classes will allow you to build a quick bootstrap file and use that. Unprotected doesn't mean that it is completely open though, it still requires a hash which is pretty hard to guess.
Title: Re: Theme/template class
Post by: Joshua Dickerson on February 18, 2014, 03:13:30 pm
Okay, now I am getting crazy - https://github.com/joshuaadickerson/Elkarte/tree/View
Title: Re: Theme/template class
Post by: Joshua Dickerson on February 18, 2014, 05:28:12 pm
I had this idea that a theme author would be able to do something like $layers->advance() and it would go through all of the layers, forward and backwards until it got back to the current layer. Then it would continue on. This way you can have a single template (like index) and put $layers->advance() where the content would be.

I think I am going to implement layers as an ArrayIterator instance.

I am trying to figure out how the flow should go though...
The application/bootstrap sets up the View and sets what the theme should be
The Controller returns a ThemeResponse() which contains Language's, Template's, Layer's, and Asset's to load (used ' because they are each a class).
The Dispatcher (whatever it is called) handles the Response and tells the View to load the Templates (just the files).
The Template manager now has classes and global subtemplates and methods registered.
The View has added in the AssetManager and Translator to allow the templates to easily interact with them
Now, the view calls LayerManager::render() which does a loop of the layers (same as obExit()). Each layer consists of [(string) $template, (string) $method]
LayerManager calls TemplateManager->renderLayer() which calls the template.

Seems like a lot more than it is. The big thing is that the templates can advance on their own if they want.
Title: Re: Theme/template class
Post by: Nao on March 19, 2014, 05:02:17 pm
First thing I'd do here is make all public methods static. Having to specify getInstance every time would be a pita... ^^ just my 2c.
Title: Re: Theme/template class
Post by: Joshua Dickerson on March 19, 2014, 05:58:16 pm
There's no singletons in this and I wouldn't make any method static.
Title: Re: Theme/template class
Post by: emanuele on March 16, 2015, 06:29:58 am
I have an idea to revive this for 1.1, keeping in mind backward compatibility and timing... and speaking of timing, it reminds me I wrote a post few days ago that is worth posting.
Title: Re: Theme/template class
Post by: Joshua Dickerson on March 24, 2015, 02:57:59 am
I made mention of it on the PR on GH, but I'll explain it here.

The hook calls made in https://github.com/joshuaadickerson/Elkarte/blob/View/sources/View/TemplateManager.php#L375 cannot be used. They will be called for every template, but you might it to only happen for certain templates. The solution is another system for template events.

I am thinking two tables for this - 1 for the events and 1 to link the theme and events. The template_events table should have the same as the events table from my Hooker idea and link table is just id_event and id_theme. If id_theme = 0, it is a global; id_theme = 1 is default theme. The registered events (listeners) can be cached as a serialized array in the theme settings.

When the settings are loaded, it checks for theme_events (or whatever it is called) and registers those listeners. This happens when the theme is loaded.

If you use the Hooker event system (or whatever of it) you can pretty much just use the entire thing, just change where the debug info goes and create another object with different listeners.
Title: Re: Theme/template class
Post by: Joshua Dickerson on December 16, 2015, 06:33:13 pm
What do you think about adding the functions in for this for 1.1? Maybe not do all of the calls since that would be a lot of testing and might cause a lot of conflicts while we're trying to fix other things.
Title: Re: Theme/template class
Post by: emanuele on December 17, 2015, 02:17:58 am
I'd say next round.
Change the templating "just before" the release is calling for an endless beta phase. We already did that twice this time (ILA and the parser), I think it's enough.
Title: Re: Theme/template class
Post by: Keiro on December 19, 2015, 05:00:55 pm
Would you guys like me to add this sort of stuff to my repository and add Travis-CI integration like you guys have?

It'd keep your repositories clean and allow for you guys to be able to quickly check build status. Thoughts?
Title: Re: Theme/template class
Post by: emanuele on December 20, 2015, 06:23:11 pm
Yep, move it to the current code would be a very good first step indeed! :D

On a somehow related note: recently @gallant mentioned about Twig, we should think about the possibility to give the option to use other template engine "easily". Well, somehow. :P
Title: Re: Theme/template class
Post by: Keiro on December 20, 2015, 07:06:23 pm
Quote from: emanuele – Yep, move it to the current code would be a very good first step indeed! :D

On a somehow related note: recently @gallant mentioned about Twig, we should think about the possibility to give the option to use other template engine "easily". Well, somehow. :P

OK. I'll set up a Travis CI integration for the development branch of Elkarte.
Title: Re: Theme/template class
Post by: riddick1234 on December 22, 2015, 02:45:04 am
Thank ..
Title: Re: Theme/template class
Post by: TE on December 23, 2015, 12:54:02 am
Quote from: emanuele – On a somehow related note: recently @gallant mentioned about Twig, we should think about the possibility to give the option to use other template engine "easily". Well, somehow. :P
Twig seems popular these days.. but I'd personally favour Mustache.. It's a logicless template engine and available in various programming languages (PHP, Javascript,, Ruby, Python)..
https://github.com/bobthecow/mustache.php
Title: Re: Theme/template class
Post by: Joshua Dickerson on December 23, 2015, 10:51:15 am
I should change Theme::subtemplate($template, $args) to render($template, $args) which aligns it closer to other template engines.
Title: Re: Theme/template class
Post by: Joshua Dickerson on January 07, 2016, 03:16:41 am
Interested to hear some opinions: could subtemplates ( $context['sub_template'] ) be replaced with layers? The subtemplate is just the inner most layer, right? It just doesn't have _above or _below.
Title: Re: Theme/template class
Post by: emanuele on January 07, 2016, 03:37:57 am
TBH I don't know, layers are a bit of pain to understand and handle properly (just the fact that "after the center" you are forced to think the opposite of what you want to obtain is... counter intuitive. lol

At some point I had two opposite, but somehow similar, ideas about what to do here:
1) a layout manager java-like,
2) a stack of sub-templates.
The first would be something like the way simple portal handles blocks, the second would be similar to layers, but just a "add this sub-template in this place" kind of thing, without any above/below.

The first option could be rather complex to code and to work with, the second could be enough in most of the cases.
Title: Re: Theme/template class
Post by: Joshua Dickerson on January 07, 2016, 01:30:30 pm
I just think it makes it easier to understand the flow. Currently, it jumps from the layers to get all of the above layers. Then it goes away from that and gets the sub/main template. Then it goes below to get all of the below layers. I guess you'd still have the same idea but now it would be in one class. I guess I would call it the "core" template to play on the layers idea ;)
Title: Re: Theme/template class
Post by: Joshua Dickerson on January 08, 2016, 12:06:51 am
Another crazy idea I just had... stop playing with layers in the controller. Instead, move all of that to the theme. The controller loads the theme. The theme contains options and more importantly, methods that tell what layers to load, in which order, and what templates to call. I used to think it was a huge waste to have to define what goes above and below a template, but this adds a layer of abstraction which makes that a non-issue. I don't know of any "themes" that change layers or templates so this level of abstraction doesn't really get in the way. It does allow for something cool though - other template engines. Now, it would work the same way that Twig does - load and then render. Don't worry about layers.

So, how does it look? Let me try with action_display() from the display controller.

The way it works now is the controller loads 'Display' and assigns the sub_template 'messages'. Then it adds a layer at the end 'messages_information'. It does a bunch of stuff like loading up the topic, its messages, attachments, members, etc and putting them in to $context. It also loads up some Javascript files and other stuff. Then it returns (null) and obexit() does all of the work of outputting that. It reads the layers array, calling the templates _above() and then it calls the sub_template and then it does the _below().

That's pretty much how it works for all actions. The display action is about 550 lines. It's mostly a bunch of assigning $topicinfo to $context variables. The developers (Spuds and Emanuele mostly) did an awesome job at removing the queries from the actions, but they are still very heavy. Anyway, let me outline the proposal.

Just like before, somewhere early on the template will get loaded. $theme->load('topic.display') would load the template but it would return any settings that it thinks the controller should have. It then loads the topic, messages, attachments, members, etc. All of that gets put in to containers which are automatically passed to the theme. So, the controller explicitly passes the topic id and that's about it when it calls $theme->render(['topic' => $topic]) at the end of the function. Alternatively, you can just return an array and it would be handled. So long as you don't return a separate kind of response (XML or JSON) it accepts a ThemeResponse. When you call load() it checks if there are any functions/services that match it and changes the layers (in this case, it would call the display init function) and that would set the layers. It could also just do that on render. Probably better in render(). When it renders, it does the same thing as obExit().

Notice that I didn't say anything about setting up $context variables. Context handlers accept an object (Topic, Message, etc.) and return a new object with easy-to-access properties like href, link, etc. You call it in the template by using $msg = $this->context($message) and then $msg is a magic MessageContext object.

I think it would make everything a little bit nicer. It certainly reduces the size of the controllers which is always awesome (fat model / thin controller). You might think it adds logic to the "view" but that's not true. The templates don't contain any more logic. The theme has the same amount of control (layers were always available in the theme). You are only changing settings.

Please ask questions. There's some things that I didn't explain so there has to be some. I am excited about it.
Title: Re: Theme/template class
Post by: Joshua Dickerson on January 08, 2016, 04:30:14 am
Since the theme is loaded before the controller is dispatched, the theme should set any event listeners at that time. There are really very few times that the theme needs to talk to the controller or model so that's not a big deal. There are 19 _init templates including the theme's template_init(). All but two are just loading one of the generic templates so they don't need to be done before the controller. Only 1 actually makes any changes.

So, really, the render call can be exactly like Twig. That means we can have swappable template systems with ease :D
Title: Re: Theme/template class
Post by: inter on February 06, 2016, 10:07:31 am
offtop?

More templating! (express)

Example:
Code: [Select]
$app->default_view_engine = 'php'; // if extension is empty
...
$response->render('/path/to/file', $data); // php
$response->render('/path/to/file.php', $data); // php
$response->render('/path/to/file.twig', $data); // twig
$response->render('/path/to/file.jade', $data); // jade
$response->render('/path/to/file.haml', $data); // haml
Title: Re: Theme/template class
Post by: Joshua Dickerson on March 23, 2017, 08:32:49 pm
I just reread this. No wonder there wasn't a response. It was confusing. I don't want to retype everything, but let me know if it's confusing to anyone.
Title: Re: Theme/template class
Post by: emanuele on March 24, 2017, 08:43:20 am
I never managed to find a way to deal with templates that satisfies me completely in all these year.

I think I got most of what you described, but now I started adding too much to it and probably missed the point, so I'll re-read it this evening. lol