Scriptify.ru

Pico CMS очень проста в изучении. Ядро движка состоит всего из четырех файлов:

  • Pico.php необходим для работы с файлами;
  • PicoTwigExtension.php содержит дополнительные фильтры для шаблонов;
  • PicoPluginInterface.php и AbstractPicoPlugin.php необходимы для работы плагинов;

Входной скрипт (index.php)

Во входном скрипте производится проверка версии php и загрузка зависимостей. Экземпляр Pico при помощи конструктора задает пути к конфигурации, плагинам, шаблонам. Метод run() выполняет всю основную работу по загрузке страницы:

<?php 

// проверка версии PHP
if (PHP_VERSION_ID < 50306) {  
    die('Pico requires PHP 5.3.6 or above to run');  
}  

// загрузка зависимостей
require_once(__DIR__ . '/vendor/autoload.php');  

// определение путей к файлам
$pico = new Pico(  
    __DIR__, 
    'config/', 
    'plugins/', 
    'themes/' 
);  

// запуск приложения
echo $pico->run();

Обзор кода Pico.php

Класс Pico выполняет всю основную работу по загрузке страницы: загружает плагины, конфигурацию, осуществляет парсинг и рендеринг. Рассмотрим его код подробнее.

Определение абсолютных путей к файлам

Конструктор определяет абсолютные пути к необходимым файлам. Аргументы конструктора задаются во входном скрипте index.php:

    public function __construct($rootDir, $configDir, $pluginsDir, $themesDir) 
    { 
        $this->rootDir = rtrim($rootDir, '/\\') . '/'; 
        $this->configDir = $this->getAbsolutePath($configDir); 
        $this->pluginsDir = $this->getAbsolutePath($pluginsDir); 
        $this->themesDir = $this->getAbsolutePath($themesDir); 
    }

Загрузка плагинов

Метод loadPlugins() определяет пути к плагинам, подключает их при помощи require_once и создает экземпляр класса каждого из них:

    protected function loadPlugins() 
    { 
        $this->plugins = array();

        $pluginFiles = $this->getFiles($this->getPluginsDir(), '.php'); 
        foreach ($pluginFiles as $pluginFile) { 
            require_once($pluginFile); 

            $className = preg_replace('/^[0-9]+-/', '', basename($pluginFile, '.php')); 
            if (class_exists($className)) { 

                $plugin = new $className($this); 
                $className = get_class($plugin); 

                $this->plugins[$className] = $plugin; 
            } else { 

            } 
        } 
    }

Загрузка конфигурации

Метод loadConfig() включает файл конфигурации в исполняемый код. Если конфигурация не настроена, то загружаются настройки по умолчанию:

    protected function loadConfig()
    {
        $config = null;
        if (file_exists($this->getConfigDir() . 'config.php')) {
            require($this->getConfigDir() . 'config.php');
        }

        $defaultConfig = array(
            'site_title' => 'Pico',
            'base_url' => '',
            'rewrite_url' => null,
            'theme' => 'default',
            'date_format' => '%D %T',
            'twig_config' => array('cache' => false, 'autoescape' => false, 'debug' => false),
            'pages_order_by' => 'alpha',
            'pages_order' => 'asc',
            'content_dir' => null,
            'content_ext' => '.md',
            'timezone' => ''
        );
        ...
    }

Получение строки GET-запроса

Метод evaluateRequestUrl() позволяет получить строку GET-запроса, что необходимо в дальнейшем для загрузки содержимого файла:

protected function evaluateRequestUrl() {
    $pathComponent = isset($_SERVER['QUERY_STRING']) ? $_SERVER['QUERY_STRING'] : '';
    if (($pathComponentLength = strpos($pathComponent, '&')) !== false) {
        $pathComponent = substr($pathComponent, 0, $pathComponentLength);
    }
    $this->requestUrl = (strpos($pathComponent, '=') === false) ? rawurldecode($pathComponent) : '';
    $this->requestUrl = trim($this->requestUrl, '/');
}

Определение запрашиваемого файла

Метод discoverRequestFile() позволяет определить путь к запрашиваемому файлу на основе GET-запроса. Если запрос пустой, то возвращается index.md:

    protected function discoverRequestFile()
    {
        if (empty($this->requestUrl)) {
            $this->requestFile = $this->getConfig('content_dir') . 'index' . $this->getConfig('content_ext');
        } else {
            $requestUrl = str_replace('\\', '/', $this->requestUrl);
            $requestUrlParts = explode('/', $requestUrl);

            $requestFileParts = array();
            foreach ($requestUrlParts as $requestUrlPart) {
                if (($requestUrlPart === '') || ($requestUrlPart === '.')) {
                    continue;
                } elseif ($requestUrlPart === '..') {
                    array_pop($requestFileParts);
                    continue;
                }

                $requestFileParts[] = $requestUrlPart;
            }

            if (empty($requestFileParts)) {
                $this->requestFile = $this->getConfig('content_dir') . 'index' . $this->getConfig('content_ext');
                return;
            }

            $this->requestFile = $this->getConfig('content_dir') . implode('/', $requestFileParts);
            if (is_dir($this->requestFile)) {
                $indexFile = $this->requestFile . '/index' . $this->getConfig('content_ext');
                if (file_exists($indexFile) || !file_exists($this->requestFile . $this->getConfig('content_ext'))) {
                    $this->requestFile = $indexFile;
                    return;
                }
            }
            $this->requestFile .= $this->getConfig('content_ext');
        }
    }

Загрузка содержимого файла

Метод loadFileContent() загружает содержимое файла в виде строки. Если файл не существует, то загружается содержимое из 404.md:

$notFoundFile = '404' . $this->getConfig('content_ext');

    if (file_exists($this->requestFile) && (basename($this->requestFile) !== $notFoundFile)) {
        $this->rawContent = $this->loadFileContent($this->requestFile);
    } 

    else {
        $this->triggerEvent('on404ContentLoading', array(&$this->requestFile));

        header($_SERVER['SERVER_PROTOCOL'] . ' 404 Not Found');
        $this->rawContent = $this->load404Content($this->requestFile);

        $this->triggerEvent('on404ContentLoaded', array(&$this->rawContent));
    }

Парсинг мета-данных страницы

Мета-данные - это набор ключевых слов, необходимых для обозначения заголовка статьи, краткого описания, даты и других дополнительных переменных. Эти данные должны заключаются между разделителями ---. За парсинг мета-данных отвечает метод parseFileMeta(), который использует компонент Symphony YAML.

    public function parseFileMeta($rawContent, array $headers)
    {
        $meta = array();
        $pattern = "/^(\/(\*)|---)[[:blank:]]*(?:\r)?\n"
            . "(?:(.*?)(?:\r)?\n)?(?(2)\*\/|---)[[:blank:]]*(?:(?:\r)?\n|$)/s";
        if (preg_match($pattern, $rawContent, $rawMetaMatches) && isset($rawMetaMatches[3])) {
            $yamlParser = new \Symfony\Component\Yaml\Parser();
            $meta = $yamlParser->parse($rawMetaMatches[3]);
            $meta = ($meta !== null) ? array_change_key_case($meta, CASE_LOWER) : array();

            foreach ($headers as $fieldId => $fieldName) {
                $fieldName = strtolower($fieldName);
                if (isset($meta[$fieldName])) {
                    // rename field (e.g. remove whitespaces)
                    if ($fieldId != $fieldName) {
                        $meta[$fieldId] = $meta[$fieldName];
                        unset($meta[$fieldName]);
                    }
                } elseif (!isset($meta[$fieldId])) {
                    // guarantee array key existance
                    $meta[$fieldId] = '';
                }
            }
            if (!empty($meta['date'])) {
                if (is_int($meta['date'])) {
                    $meta['time'] = $meta['date'];
                    $rawDateFormat = (date('H:i:s', $meta['time']) === '00:00:00') ? 'Y-m-d' : 'Y-m-d H:i:s';
                    $meta['date'] = date($rawDateFormat, $meta['time']);
                } else {
                    $meta['time'] = strtotime($meta['date']);
                }
                $meta['date_formatted'] = utf8_encode(strftime($this->getConfig('date_format'), $meta['time']));
            } else {
                $meta['time'] = $meta['date_formatted'] = '';
            }
        } else {
            // guarantee array key existance
            $meta = array_fill_keys(array_keys($headers), '');
            $meta['time'] = $meta['date_formatted'] = '';
        }

        return $meta;
    }

Парсинг содержимого страницы

Для парсинга markdown разметки используется библиотека Parsedown. Сначала содержимое страницы подготавливается при помощи метода prepareFileContent(), то есть удаляются заголовки и подставляются данные в переменные:

    public function prepareFileContent($rawContent, array $meta)
    {

        $metaHeaderPattern = "/^(\/(\*)|---)[[:blank:]]*(?:\r)?\n"
            . "(?:(.*?)(?:\r)?\n)?(?(2)\*\/|---)[[:blank:]]*(?:(?:\r)?\n|$)/s";
        $content = preg_replace($metaHeaderPattern, '', $rawContent, 1);

        $content = str_replace('%site_titlе%', $this->getConfig('site_title'), $content);

        if ($this->isUrlRewritingEnabled()) {
            $content = str_replace('%basе_url%?', $this->getBaseUrl(), $content);
        } else {
            $content = str_replace('%basе_url%?', $this->getBaseUrl() . '?', $content);
        }
        $content = str_replace('%basе_url%', rtrim($this->getBaseUrl(), '/'), $content);

        $themeUrl = $this->getBaseUrl() . basename($this->getThemesDir()) . '/' . $this->getConfig('theme');
        $content = str_replace('%themе_url%', $themeUrl, $content);
        if (!empty($meta)) {
            $metaKeys = $metaValues = array();
            foreach ($meta as $metaKey => $metaValue) {
                if (is_scalar($metaValue) || ($metaValue === null)) {
                    $metaKeys[] = '%meta.' . $metaKey . '%';
                    $metaValues[] = strval($metaValue);
                }
            }
            $content = str_replace($metaKeys, $metaValues, $content);
        }
        return $content;
    }

Затем полученный контент преобразуется в html код:

public function parseFileContent($content)
{
    if ($this->parsedown === null) {
        throw new LogicException("Unable to parse file contents: Parsedown instance wasn't registered yet");
    }

    return $this->parsedown->text($content);
}

Навигация между страницами

Метод readPages() необходим для получения массива данных от всех существующих страниц на сайте. В дальнейшем это будет необходимо для сортировки страниц и навигации.

    protected function readPages()
    {
        $this->pages = array();
        $files = $this->getFiles($this->getConfig('content_dir'), $this->getConfig('content_ext'), Pico::SORT_NONE);
        foreach ($files as $i => $file) {
            // skip 404 page
            if (basename($file) === '404' . $this->getConfig('content_ext')) {
                unset($files[$i]);
                continue;
            }

            $id = substr($file, strlen($this->getConfig('content_dir')), -strlen($this->getConfig('content_ext')));

            // drop inaccessible pages (e.g. drop "sub.md" if "sub/index.md" exists)
            $conflictFile = $this->getConfig('content_dir') . $id . '/index' . $this->getConfig('content_ext');
            if (in_array($conflictFile, $files, true)) {
                continue;
            }

            $url = $this->getPageUrl($id);
            if ($file != $this->requestFile) {
                $rawContent = file_get_contents($file);

                $headers = $this->getMetaHeaders();
                try {
                    $meta = $this->parseFileMeta($rawContent, $headers);
                } catch (\Symfony\Component\Yaml\Exception\ParseException $e) {
                    $meta = $this->parseFileMeta('', $headers);
                    $meta['YAML_ParseError'] = $e->getMessage();
                }
            } else {
                $rawContent = &$this->rawContent;
                $meta = &$this->meta;
            }

            $page = array(
                'id' => $id,
                'url' => $url,
                'title' => &$meta['title'],
                'description' => &$meta['description'],
                'author' => &$meta['author'],
                'time' => &$meta['time'],
                'date' => &$meta['date'],
                'date_formatted' => &$meta['date_formatted'],
                'raw_content' => &$rawContent,
                'meta' => &$meta
            );

            if ($file === $this->requestFile) {
                $page['content'] = &$this->content;
            }

            unset($rawContent, $meta);

            // trigger event
            $this->triggerEvent('onSinglePageLoaded', array(&$page));

            $this->pages[$id] = $page;
        }
    }

Сортировка страниц

Метод sortPages() необходим для сортировки массива страниц по дате или алфавиту:

protected function sortPages()
    {
        // тип сортировки (по возрастанию или убыванию)
        $order = $this->getConfig('pages_order');
        // анонимная функция для сортировки по имени файла
        $alphaSortClosure = function ($a, $b) use ($order) {
            $aSortKey = (basename($a['id']) === 'index') ? dirname($a['id']) : $a['id'];
            $bSortKey = (basename($b['id']) === 'index') ? dirname($b['id']) : $b['id'];
            // сравниваем строки
            $cmp = strcmp($aSortKey, $bSortKey);
            return $cmp * (($order === 'desc') ? -1 : 1);
        };

        if ($this->getConfig('pages_order_by') === 'date') {
            // сортируем массив, используя анонимную функцию
            uasort($this->pages, function ($a, $b) use ($alphaSortClosure, $order) {
                if (empty($a['time']) || empty($b['time'])) {
                    $cmp = (empty($a['time']) - empty($b['time']));
                } else {
                    $cmp = ($b['time'] - $a['time']);
                }
                if ($cmp === 0) {
                    return $alphaSortClosure($a, $b);
                }

                return $cmp * (($order === 'desc') ? 1 : -1);
            });
        } else {
            // сортировать в алфавитном порядке
            uasort($this->pages, $alphaSortClosure);
        }
    }

Определение предыдущей, текущей и следующей страницы

Метод discoverCurrentPage() определяет индексы текущей, предыдущей и следующей страницы. Благодаря этому методу можно указывать в шаблонах ссылки на них при помощи {{ prev_page }}, {{ current_page }} и {{ next_page }}:

    protected function discoverCurrentPage()
    {
        $pageIds = array_keys($this->pages);

        $contentDir = $this->getConfig('content_dir');
        $contentDirLength = strlen($contentDir);

        if (substr($this->requestFile, 0, $contentDirLength) !== $contentDir) {
            return;
        }

        $contentExt = $this->getConfig('content_ext');
        $currentPageId = substr($this->requestFile, $contentDirLength, -strlen($contentExt));
        $currentPageIndex = array_search($currentPageId, $pageIds);
        if ($currentPageIndex !== false) {
            $this->currentPage = &$this->pages[$currentPageId];

            if (($this->getConfig('order_by') === 'date') && ($this->getConfig('order') === 'desc')) {
                $previousPageOffset = 1;
                $nextPageOffset = -1;
            } else {
                $previousPageOffset = -1;
                $nextPageOffset = 1;
            }

            if (isset($pageIds[$currentPageIndex + $previousPageOffset])) {
                $previousPageId = $pageIds[$currentPageIndex + $previousPageOffset];
                $this->previousPage = &$this->pages[$previousPageId];
            }

            if (isset($pageIds[$currentPageIndex + $nextPageOffset])) {
                $nextPageId = $pageIds[$currentPageIndex + $nextPageOffset];
                $this->nextPage = &$this->pages[$nextPageId];
            }
        }
    }

Загрузка шаблонизатора Twig. Создание пользовательских фильтров

Метод registerTwig() необходим для загрузки шаблонизатора и создания пользовательских фильтров. Фильтры необходимы для преобразований над переменными в шаблонах:

protected function registerTwig()
    {
        $twigLoader = new Twig_Loader_Filesystem($this->getThemesDir() . $this->getConfig('theme'));
        $this->twig = new Twig_Environment($twigLoader, $this->getConfig('twig_config'));
        $this->twig->addExtension(new Twig_Extension_Debug());
        $this->twig->addExtension(new PicoTwigExtension($this));

        // register link filter
        $this->twig->addFilter(new Twig_SimpleFilter('link', array($this, 'getPageUrl')));

        $pico = $this;
        $pages = &$this->pages;
        $this->twig->addFilter(new Twig_SimpleFilter('content', function ($page) use ($pico, &$pages) {
            if (isset($pages[$page])) {
                $pageData = &$pages[$page];
                if (!isset($pageData['content'])) {
                    $pageData['content'] = $pico->prepareFileContent($pageData['raw_content'], $pageData['meta']);
                    $pageData['content'] = $pico->parseFileContent($pageData['content']);
                }
                return $pageData['content'];
            }
            return null;
        }));
    }

Метод getTwigVariables() возвращает массив, в котором содержатся соответствия между переменными шаблона и их значениями:

    protected function getTwigVariables()
    {
        $frontPage = $this->getConfig('content_dir') . 'index' . $this->getConfig('content_ext');
        return array(
            'config' => $this->getConfig(),
            'base_dir' => rtrim($this->getRootDir(), '/'),
            'base_url' => rtrim($this->getBaseUrl(), '/'),
            'theme_dir' => $this->getThemesDir() . $this->getConfig('theme'),
            'theme_url' => $this->getBaseUrl() . basename($this->getThemesDir()) . '/' . $this->getConfig('theme'),
            'rewrite_url' => $this->isUrlRewritingEnabled(),
            'site_title' => $this->getConfig('site_title'),
            'meta' => $this->meta,
            'content' => $this->content,
            'pages' => $this->pages,
            'prev_page' => $this->previousPage,
            'current_page' => $this->currentPage,
            'next_page' => $this->nextPage,
            'is_front_page' => ($this->requestFile === $frontPage),
        );
    }

Наконец, мы определяем имя шаблона, его расширение и производим рендеринг страницы:

    if (isset($this->meta['template']) && $this->meta['template']) {
        $templateName = $this->meta['template'];
    } else {
        $templateName = 'index';
    }
    if (file_exists($this->getThemesDir() . $this->getConfig('theme') . '/' . $templateName . '.twig')) {
        $templateName .= '.twig';
    } else {
        $templateName .= '.html';
    }

    $this->triggerEvent('onPageRendering', array(&$this->twig, &$this->twigVariables, &$templateName));

    $output = $this->twig->render($templateName, $this->twigVariables);
    $this->triggerEvent('onPageRendered', array(&$output));

    return $output;

В этом и заключается весь принцип работы Pico CMS. В целом, данного функционала вполне достаточно для ведения личного блога.

Содержание статьи