Merge pull request #1645 from ArthurHoaro/feature/plugin-register-route
Plugin system: allow plugins to provide custom routes
This commit is contained in:
commit
bd11879018
13 changed files with 270 additions and 13 deletions
|
@ -50,6 +50,9 @@ class ContainerBuilder
|
||||||
/** @var LoginManager */
|
/** @var LoginManager */
|
||||||
protected $login;
|
protected $login;
|
||||||
|
|
||||||
|
/** @var PluginManager */
|
||||||
|
protected $pluginManager;
|
||||||
|
|
||||||
/** @var LoggerInterface */
|
/** @var LoggerInterface */
|
||||||
protected $logger;
|
protected $logger;
|
||||||
|
|
||||||
|
@ -61,12 +64,14 @@ public function __construct(
|
||||||
SessionManager $session,
|
SessionManager $session,
|
||||||
CookieManager $cookieManager,
|
CookieManager $cookieManager,
|
||||||
LoginManager $login,
|
LoginManager $login,
|
||||||
|
PluginManager $pluginManager,
|
||||||
LoggerInterface $logger
|
LoggerInterface $logger
|
||||||
) {
|
) {
|
||||||
$this->conf = $conf;
|
$this->conf = $conf;
|
||||||
$this->session = $session;
|
$this->session = $session;
|
||||||
$this->login = $login;
|
$this->login = $login;
|
||||||
$this->cookieManager = $cookieManager;
|
$this->cookieManager = $cookieManager;
|
||||||
|
$this->pluginManager = $pluginManager;
|
||||||
$this->logger = $logger;
|
$this->logger = $logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -78,12 +83,10 @@ public function build(): ShaarliContainer
|
||||||
$container['sessionManager'] = $this->session;
|
$container['sessionManager'] = $this->session;
|
||||||
$container['cookieManager'] = $this->cookieManager;
|
$container['cookieManager'] = $this->cookieManager;
|
||||||
$container['loginManager'] = $this->login;
|
$container['loginManager'] = $this->login;
|
||||||
|
$container['pluginManager'] = $this->pluginManager;
|
||||||
$container['logger'] = $this->logger;
|
$container['logger'] = $this->logger;
|
||||||
$container['basePath'] = $this->basePath;
|
$container['basePath'] = $this->basePath;
|
||||||
|
|
||||||
$container['plugins'] = function (ShaarliContainer $container): PluginManager {
|
|
||||||
return new PluginManager($container->conf);
|
|
||||||
};
|
|
||||||
|
|
||||||
$container['history'] = function (ShaarliContainer $container): History {
|
$container['history'] = function (ShaarliContainer $container): History {
|
||||||
return new History($container->conf->get('resource.history'));
|
return new History($container->conf->get('resource.history'));
|
||||||
|
@ -113,14 +116,6 @@ public function build(): ShaarliContainer
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
$container['pluginManager'] = function (ShaarliContainer $container): PluginManager {
|
|
||||||
$pluginManager = new PluginManager($container->conf);
|
|
||||||
|
|
||||||
$pluginManager->load($container->conf->get('general.enabled_plugins'));
|
|
||||||
|
|
||||||
return $pluginManager;
|
|
||||||
};
|
|
||||||
|
|
||||||
$container['formatterFactory'] = function (ShaarliContainer $container): FormatterFactory {
|
$container['formatterFactory'] = function (ShaarliContainer $container): FormatterFactory {
|
||||||
return new FormatterFactory(
|
return new FormatterFactory(
|
||||||
$container->conf,
|
$container->conf,
|
||||||
|
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
use Shaarli\Config\ConfigManager;
|
use Shaarli\Config\ConfigManager;
|
||||||
use Shaarli\Plugin\Exception\PluginFileNotFoundException;
|
use Shaarli\Plugin\Exception\PluginFileNotFoundException;
|
||||||
|
use Shaarli\Plugin\Exception\PluginInvalidRouteException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class PluginManager
|
* Class PluginManager
|
||||||
|
@ -26,6 +27,14 @@ class PluginManager
|
||||||
*/
|
*/
|
||||||
private $loadedPlugins = [];
|
private $loadedPlugins = [];
|
||||||
|
|
||||||
|
/** @var array List of registered routes. Contains keys:
|
||||||
|
* - `method`: HTTP method, GET/POST/PUT/PATCH/DELETE
|
||||||
|
* - `route` (path): without prefix, e.g. `/up/{variable}`
|
||||||
|
* It will be later prefixed by `/plugin/<plugin name>/`.
|
||||||
|
* - `callable` string, function name or FQN class's method, e.g. `demo_plugin_custom_controller`.
|
||||||
|
*/
|
||||||
|
protected $registeredRoutes = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var ConfigManager Configuration Manager instance.
|
* @var ConfigManager Configuration Manager instance.
|
||||||
*/
|
*/
|
||||||
|
@ -86,6 +95,9 @@ public function load($authorizedPlugins)
|
||||||
$this->loadPlugin($dirs[$index], $plugin);
|
$this->loadPlugin($dirs[$index], $plugin);
|
||||||
} catch (PluginFileNotFoundException $e) {
|
} catch (PluginFileNotFoundException $e) {
|
||||||
error_log($e->getMessage());
|
error_log($e->getMessage());
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$error = $plugin . t(' [plugin incompatibility]: ') . $e->getMessage();
|
||||||
|
$this->errors = array_unique(array_merge($this->errors, [$error]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -166,6 +178,22 @@ private function loadPlugin($dir, $pluginName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$registerRouteFunction = $pluginName . '_register_routes';
|
||||||
|
$routes = null;
|
||||||
|
if (function_exists($registerRouteFunction)) {
|
||||||
|
$routes = call_user_func($registerRouteFunction);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($routes !== null) {
|
||||||
|
foreach ($routes as $route) {
|
||||||
|
if (static::validateRouteRegistration($route)) {
|
||||||
|
$this->registeredRoutes[$pluginName][] = $route;
|
||||||
|
} else {
|
||||||
|
throw new PluginInvalidRouteException($pluginName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$this->loadedPlugins[] = $pluginName;
|
$this->loadedPlugins[] = $pluginName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -237,6 +265,14 @@ public function getPluginsMeta()
|
||||||
return $metaData;
|
return $metaData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array List of registered custom routes by plugins.
|
||||||
|
*/
|
||||||
|
public function getRegisteredRoutes(): array
|
||||||
|
{
|
||||||
|
return $this->registeredRoutes;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return the list of encountered errors.
|
* Return the list of encountered errors.
|
||||||
*
|
*
|
||||||
|
@ -246,4 +282,32 @@ public function getErrors()
|
||||||
{
|
{
|
||||||
return $this->errors;
|
return $this->errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether provided input is valid to register a new route.
|
||||||
|
* It must contain keys `method`, `route`, `callable` (all strings).
|
||||||
|
*
|
||||||
|
* @param string[] $input
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
protected static function validateRouteRegistration(array $input): bool
|
||||||
|
{
|
||||||
|
if (
|
||||||
|
!array_key_exists('method', $input)
|
||||||
|
|| !in_array(strtoupper($input['method']), ['GET', 'PUT', 'PATCH', 'POST', 'DELETE'])
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!array_key_exists('route', $input) || !preg_match('#^[a-z\d/\.\-_]+$#', $input['route'])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!array_key_exists('callable', $input)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
26
application/plugin/exception/PluginInvalidRouteException.php
Normal file
26
application/plugin/exception/PluginInvalidRouteException.php
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shaarli\Plugin\Exception;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class PluginFileNotFoundException
|
||||||
|
*
|
||||||
|
* Raise when plugin files can't be found.
|
||||||
|
*/
|
||||||
|
class PluginInvalidRouteException extends Exception
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Construct exception with plugin name.
|
||||||
|
* Generate message.
|
||||||
|
*
|
||||||
|
* @param string $pluginName name of the plugin not found
|
||||||
|
*/
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->message = 'trying to register invalid route.';
|
||||||
|
}
|
||||||
|
}
|
|
@ -139,6 +139,31 @@ Each file contain two keys:
|
||||||
|
|
||||||
> Note: In PHP, `parse_ini_file()` seems to want strings to be between by quotes `"` in the ini file.
|
> Note: In PHP, `parse_ini_file()` seems to want strings to be between by quotes `"` in the ini file.
|
||||||
|
|
||||||
|
### Register plugin's routes
|
||||||
|
|
||||||
|
Shaarli lets you register custom Slim routes for your plugin.
|
||||||
|
|
||||||
|
To register a route, the plugin must include a function called `function <plugin_name>_register_routes(): array`.
|
||||||
|
|
||||||
|
This method must return an array of routes, each entry must contain the following keys:
|
||||||
|
|
||||||
|
- `method`: HTTP method, `GET/POST/PUT/PATCH/DELETE`
|
||||||
|
- `route` (path): without prefix, e.g. `/up/{variable}`
|
||||||
|
It will be later prefixed by `/plugin/<plugin name>/`.
|
||||||
|
- `callable` string, function name or FQN class's method to execute, e.g. `demo_plugin_custom_controller`.
|
||||||
|
|
||||||
|
Callable functions or methods must have `Slim\Http\Request` and `Slim\Http\Response` parameters
|
||||||
|
and return a `Slim\Http\Response`. We recommend creating a dedicated class and extend either
|
||||||
|
`ShaarliVisitorController` or `ShaarliAdminController` to use helper functions they provide.
|
||||||
|
|
||||||
|
A dedicated plugin template is available for rendering content: `pluginscontent.html` using `content` placeholder.
|
||||||
|
|
||||||
|
> **Warning**: plugins are not able to use RainTPL template engine for their content due to technical restrictions.
|
||||||
|
> RainTPL does not allow to register multiple template folders, so all HTML rendering must be done within plugin
|
||||||
|
> custom controller.
|
||||||
|
|
||||||
|
Check out the `demo_plugin` for a live example: `GET <shaarli_url>/plugin/demo_plugin/custom`.
|
||||||
|
|
||||||
### Understanding relative paths
|
### Understanding relative paths
|
||||||
|
|
||||||
Because Shaarli is a self-hosted tool, an instance can either be installed at the root directory, or under a subfolder.
|
Because Shaarli is a self-hosted tool, an instance can either be installed at the root directory, or under a subfolder.
|
||||||
|
|
22
index.php
22
index.php
|
@ -31,6 +31,7 @@
|
||||||
use Shaarli\Config\ConfigManager;
|
use Shaarli\Config\ConfigManager;
|
||||||
use Shaarli\Container\ContainerBuilder;
|
use Shaarli\Container\ContainerBuilder;
|
||||||
use Shaarli\Languages;
|
use Shaarli\Languages;
|
||||||
|
use Shaarli\Plugin\PluginManager;
|
||||||
use Shaarli\Security\BanManager;
|
use Shaarli\Security\BanManager;
|
||||||
use Shaarli\Security\CookieManager;
|
use Shaarli\Security\CookieManager;
|
||||||
use Shaarli\Security\LoginManager;
|
use Shaarli\Security\LoginManager;
|
||||||
|
@ -87,7 +88,17 @@
|
||||||
|
|
||||||
$loginManager->checkLoginState(client_ip_id($_SERVER));
|
$loginManager->checkLoginState(client_ip_id($_SERVER));
|
||||||
|
|
||||||
$containerBuilder = new ContainerBuilder($conf, $sessionManager, $cookieManager, $loginManager, $logger);
|
$pluginManager = new PluginManager($conf);
|
||||||
|
$pluginManager->load($conf->get('general.enabled_plugins', []));
|
||||||
|
|
||||||
|
$containerBuilder = new ContainerBuilder(
|
||||||
|
$conf,
|
||||||
|
$sessionManager,
|
||||||
|
$cookieManager,
|
||||||
|
$loginManager,
|
||||||
|
$pluginManager,
|
||||||
|
$logger
|
||||||
|
);
|
||||||
$container = $containerBuilder->build();
|
$container = $containerBuilder->build();
|
||||||
$app = new App($container);
|
$app = new App($container);
|
||||||
|
|
||||||
|
@ -154,6 +165,15 @@
|
||||||
$this->get('/visibility/{visibility}', '\Shaarli\Front\Controller\Admin\SessionFilterController:visibility');
|
$this->get('/visibility/{visibility}', '\Shaarli\Front\Controller\Admin\SessionFilterController:visibility');
|
||||||
})->add('\Shaarli\Front\ShaarliAdminMiddleware');
|
})->add('\Shaarli\Front\ShaarliAdminMiddleware');
|
||||||
|
|
||||||
|
$app->group('/plugin', function () use ($pluginManager) {
|
||||||
|
foreach ($pluginManager->getRegisteredRoutes() as $pluginName => $routes) {
|
||||||
|
$this->group('/' . $pluginName, function () use ($routes) {
|
||||||
|
foreach ($routes as $route) {
|
||||||
|
$this->{strtolower($route['method'])}('/' . ltrim($route['route'], '/'), $route['callable']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})->add('\Shaarli\Front\ShaarliMiddleware');
|
||||||
|
|
||||||
// REST API routes
|
// REST API routes
|
||||||
$app->group('/api/v1', function () {
|
$app->group('/api/v1', function () {
|
||||||
|
|
|
@ -18,5 +18,6 @@
|
||||||
<rule ref="PSR1.Files.SideEffects.FoundWithSymbols">
|
<rule ref="PSR1.Files.SideEffects.FoundWithSymbols">
|
||||||
<!-- index.php bootstraps everything, so yes mixed symbols with side effects -->
|
<!-- index.php bootstraps everything, so yes mixed symbols with side effects -->
|
||||||
<exclude-pattern>index.php</exclude-pattern>
|
<exclude-pattern>index.php</exclude-pattern>
|
||||||
|
<exclude-pattern>plugins/*</exclude-pattern>
|
||||||
</rule>
|
</rule>
|
||||||
</ruleset>
|
</ruleset>
|
||||||
|
|
24
plugins/demo_plugin/DemoPluginController.php
Normal file
24
plugins/demo_plugin/DemoPluginController.php
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shaarli\DemoPlugin;
|
||||||
|
|
||||||
|
use Shaarli\Front\Controller\Admin\ShaarliAdminController;
|
||||||
|
use Slim\Http\Request;
|
||||||
|
use Slim\Http\Response;
|
||||||
|
|
||||||
|
class DemoPluginController extends ShaarliAdminController
|
||||||
|
{
|
||||||
|
public function index(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$this->assignView(
|
||||||
|
'content',
|
||||||
|
'<div class="center">' .
|
||||||
|
'This is a demo page. I have access to Shaarli container, so I\'m free to do whatever I want here.' .
|
||||||
|
'</div>'
|
||||||
|
);
|
||||||
|
|
||||||
|
return $response->write($this->render('pluginscontent'));
|
||||||
|
}
|
||||||
|
}
|
|
@ -7,6 +7,8 @@
|
||||||
* Can be used by plugin developers to make their own plugin.
|
* Can be used by plugin developers to make their own plugin.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/DemoPluginController.php';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* RENDER HEADER, INCLUDES, FOOTER
|
* RENDER HEADER, INCLUDES, FOOTER
|
||||||
*
|
*
|
||||||
|
@ -60,6 +62,17 @@ function demo_plugin_init($conf)
|
||||||
return $errors;
|
return $errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function demo_plugin_register_routes(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'method' => 'GET',
|
||||||
|
'route' => '/custom',
|
||||||
|
'callable' => 'Shaarli\DemoPlugin\DemoPluginController:index',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook render_header.
|
* Hook render_header.
|
||||||
* Executed on every page render.
|
* Executed on every page render.
|
||||||
|
@ -304,7 +317,11 @@ function hook_demo_plugin_render_editlink($data)
|
||||||
function hook_demo_plugin_render_tools($data)
|
function hook_demo_plugin_render_tools($data)
|
||||||
{
|
{
|
||||||
// field_plugin
|
// field_plugin
|
||||||
$data['tools_plugin'][] = 'tools_plugin';
|
$data['tools_plugin'][] = '<div class="tools-item">
|
||||||
|
<a href="' . $data['_BASE_PATH_'] . '/plugin/demo_plugin/custom">
|
||||||
|
<span class="pure-button pure-u-lg-2-3 pure-u-3-4">Demo Plugin Custom Route</span>
|
||||||
|
</a>
|
||||||
|
</div>';
|
||||||
|
|
||||||
return $data;
|
return $data;
|
||||||
}
|
}
|
||||||
|
|
|
@ -120,4 +120,43 @@ public function testGetPluginsMeta(): void
|
||||||
$this->assertEquals('test plugin', $meta[self::$pluginName]['description']);
|
$this->assertEquals('test plugin', $meta[self::$pluginName]['description']);
|
||||||
$this->assertEquals($expectedParameters, $meta[self::$pluginName]['parameters']);
|
$this->assertEquals($expectedParameters, $meta[self::$pluginName]['parameters']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test plugin custom routes - note that there is no check on callable functions
|
||||||
|
*/
|
||||||
|
public function testRegisteredRoutes(): void
|
||||||
|
{
|
||||||
|
PluginManager::$PLUGINS_PATH = self::$pluginPath;
|
||||||
|
$this->pluginManager->load([self::$pluginName]);
|
||||||
|
|
||||||
|
$expectedParameters = [
|
||||||
|
[
|
||||||
|
'method' => 'GET',
|
||||||
|
'route' => '/test',
|
||||||
|
'callable' => 'getFunction',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'method' => 'POST',
|
||||||
|
'route' => '/custom',
|
||||||
|
'callable' => 'postFunction',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
$meta = $this->pluginManager->getRegisteredRoutes();
|
||||||
|
static::assertSame($expectedParameters, $meta[self::$pluginName]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test plugin custom routes with invalid route
|
||||||
|
*/
|
||||||
|
public function testRegisteredRoutesInvalid(): void
|
||||||
|
{
|
||||||
|
$plugin = 'test_route_invalid';
|
||||||
|
$this->pluginManager->load([$plugin]);
|
||||||
|
|
||||||
|
$meta = $this->pluginManager->getRegisteredRoutes();
|
||||||
|
static::assertSame([], $meta);
|
||||||
|
|
||||||
|
$errors = $this->pluginManager->getErrors();
|
||||||
|
static::assertSame(['test_route_invalid [plugin incompatibility]: trying to register invalid route.'], $errors);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -43,11 +43,15 @@ class ContainerBuilderTest extends TestCase
|
||||||
/** @var CookieManager */
|
/** @var CookieManager */
|
||||||
protected $cookieManager;
|
protected $cookieManager;
|
||||||
|
|
||||||
|
/** @var PluginManager */
|
||||||
|
protected $pluginManager;
|
||||||
|
|
||||||
public function setUp(): void
|
public function setUp(): void
|
||||||
{
|
{
|
||||||
$this->conf = new ConfigManager('tests/utils/config/configJson');
|
$this->conf = new ConfigManager('tests/utils/config/configJson');
|
||||||
$this->sessionManager = $this->createMock(SessionManager::class);
|
$this->sessionManager = $this->createMock(SessionManager::class);
|
||||||
$this->cookieManager = $this->createMock(CookieManager::class);
|
$this->cookieManager = $this->createMock(CookieManager::class);
|
||||||
|
$this->pluginManager = $this->createMock(PluginManager::class);
|
||||||
|
|
||||||
$this->loginManager = $this->createMock(LoginManager::class);
|
$this->loginManager = $this->createMock(LoginManager::class);
|
||||||
$this->loginManager->method('isLoggedIn')->willReturn(true);
|
$this->loginManager->method('isLoggedIn')->willReturn(true);
|
||||||
|
@ -57,6 +61,7 @@ public function setUp(): void
|
||||||
$this->sessionManager,
|
$this->sessionManager,
|
||||||
$this->cookieManager,
|
$this->cookieManager,
|
||||||
$this->loginManager,
|
$this->loginManager,
|
||||||
|
$this->pluginManager,
|
||||||
$this->createMock(LoggerInterface::class)
|
$this->createMock(LoggerInterface::class)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,3 +27,19 @@ function hook_test_error()
|
||||||
{
|
{
|
||||||
new Unknown();
|
new Unknown();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function test_register_routes(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'method' => 'GET',
|
||||||
|
'route' => '/test',
|
||||||
|
'callable' => 'getFunction',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'method' => 'POST',
|
||||||
|
'route' => '/custom',
|
||||||
|
'callable' => 'postFunction',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
12
tests/plugins/test_route_invalid/test_route_invalid.php
Normal file
12
tests/plugins/test_route_invalid/test_route_invalid.php
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
function test_route_invalid_register_routes(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'method' => 'GET',
|
||||||
|
'route' => 'not a route',
|
||||||
|
'callable' => 'getFunction',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
13
tpl/default/pluginscontent.html
Normal file
13
tpl/default/pluginscontent.html
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html{if="$language !== 'auto'"} lang="{$language}"{/if}>
|
||||||
|
<head>
|
||||||
|
{include="includes"}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{include="page.header"}
|
||||||
|
|
||||||
|
{$content}
|
||||||
|
|
||||||
|
{include="page.footer"}
|
||||||
|
</body>
|
||||||
|
</html>
|
Loading…
Reference in a new issue