diff --git a/.htaccess b/.htaccess index 4fd10f335..ce48b2674 100644 --- a/.htaccess +++ b/.htaccess @@ -2,6 +2,17 @@ RewriteEngine On +## Begin RewriteBase +# If you are getting 404 errors on subpages, you may have to uncomment the RewriteBase entry +# You should change the '/' to your appropriate subfolder. For example if you have +# your Grav install at the root of your site '/' should work, else it might be something +# along the lines of: RewriteBase / +## + +# RewriteBase / + +## End - RewriteBase + ## Begin - Exploits # If you experience problems on your site block out the operations listed below # This attempts to block the most common type of exploit `attempts` to Grav @@ -19,17 +30,6 @@ RewriteRule .* index.php [F] # ## End - Exploits -## Begin RewriteBase -# If you are getting 404 errors on subpages, you may have to uncomment the RewriteBase entry -# You should change the '/' to your appropriate subfolder. For example if you have -# your Grav install at the root of your site '/' should work, else it might be something -# along the lines of: RewriteBase / -## - -# RewriteBase / - -## End - RewriteBase - ## Begin - Index # If the requested path and file is not /index.php and the request # has not already been internally rewritten to the index.php script diff --git a/CHANGELOG.md b/CHANGELOG.md index 19452528e..d35a69eef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,30 @@ +# v0.9.14 +## 01/23/2015 + +1. [](#new) + * Added **GZip** support + * Added multiple configurations via `setup.php` + * Added base structure for unit tests + * New `onPageContentRaw()` plugin event that processes before any page processing + * Added ability to dynamically set Metadata on page + * Added ability to dynamically configure Markdown processing via Parsedown options +2. [](#improved) + * Refactored `page.content()` method to be more flexible and reliable + * Various updates and fixes for streams resulting in better multi-site support + * Updated Twig, Parsedown, ParsedownExtra, DoctrineCache libraries + * Refactored Parsedown trait + * Force modular pages to be non-visible in menus + * Moved RewriteBase before Exploits in `.htaccess` + * Added standard video formats to Media support + * Added priority for inline assets + * Check for uniqueness when adding multiple inline assets + * Improved support for Twig-based URLs inside Markdown links and images + * Improved Twig `url()` function +3. [](#bugfix) + * Fix for HTML entities quotes in Metadata values + * Fix for `published` setting to have precedent of `publish_date` and `unpublish_date` + * Fix for `onShutdown()` events not closing connections properly in **php-fpm** environments + # v0.9.13 ## 01/09/2015 @@ -15,7 +42,7 @@ * House-cleaning of some unused methods in Pages object 3. [](#bugfix) * Fix `uninstall` GPM command that was broken in last release - * Fix for intermitten `undefined index` error when working with Collections + * Fix for intermittent `undefined index` error when working with Collections * Fix for date of some pages being set to incorrect future timestamps # v0.9.12 @@ -27,8 +54,8 @@ * Added support for **in-page** Twig processing in **modular** pages * Added configurable support for `undefined` Twig functions and filters 2. [](#improved) - * Fallback to default `.html` template if error occurs on non-html pages - * Added ability to have PSR-1 friendly plugin names (camelcase, no-dashes) + * Fall back to default `.html` template if error occurs on non-html pages + * Added ability to have PSR-1 friendly plugin names (CamelCase, no-dashes) * Fix to `composer.json` to deter API rate-limit errors * Added **non-exception-throwing** handler for undefined methods on `Medium` objects 3. [](#bugfix) diff --git a/bin/composer.phar b/bin/composer.phar index 3d0e95105..e46677b91 100755 Binary files a/bin/composer.phar and b/bin/composer.phar differ diff --git a/composer.json b/composer.json index b84698dfb..5e8978351 100644 --- a/composer.json +++ b/composer.json @@ -8,7 +8,7 @@ "require": { "php": ">=5.4.0", "twig/twig": "~1.16", - "erusev/parsedown-extra": "dev-master", + "erusev/parsedown-extra": "~0.6", "symfony/yaml": "~2.6", "symfony/console": "~2.6", "symfony/event-dispatcher": "~2.6", diff --git a/htaccess.txt b/htaccess.txt index 4fd10f335..ce48b2674 100644 --- a/htaccess.txt +++ b/htaccess.txt @@ -2,6 +2,17 @@ RewriteEngine On +## Begin RewriteBase +# If you are getting 404 errors on subpages, you may have to uncomment the RewriteBase entry +# You should change the '/' to your appropriate subfolder. For example if you have +# your Grav install at the root of your site '/' should work, else it might be something +# along the lines of: RewriteBase / +## + +# RewriteBase / + +## End - RewriteBase + ## Begin - Exploits # If you experience problems on your site block out the operations listed below # This attempts to block the most common type of exploit `attempts` to Grav @@ -19,17 +30,6 @@ RewriteRule .* index.php [F] # ## End - Exploits -## Begin RewriteBase -# If you are getting 404 errors on subpages, you may have to uncomment the RewriteBase entry -# You should change the '/' to your appropriate subfolder. For example if you have -# your Grav install at the root of your site '/' should work, else it might be something -# along the lines of: RewriteBase / -## - -# RewriteBase / - -## End - RewriteBase - ## Begin - Index # If the requested path and file is not /index.php and the request # has not already been internally rewritten to the index.php script diff --git a/system/config/media.yaml b/system/config/media.yaml index 40385268e..f5a4795da 100644 --- a/system/config/media.yaml +++ b/system/config/media.yaml @@ -40,6 +40,31 @@ swf: type: video thumb: media/thumb-swf.png mime: video/x-flv +flv: + type: video + thumb: media/thumb-flv.png + mime: video/x-flv + +mp3: + type: audio + thumb: media/thumb-mp3.png + mime: audio/mp3 +ogg: + type: audio + thumb: media/thumb-ogg.png + mine: audio/ogg +wma: + type: audio + thumb: media/thumb-wma.png + mine: audio/wma +m4a: + type: audio + thumb: media/thumb-m4a.png + mine: audio/m4a +wav: + type: audio + thumb: media/thumb-wav.png + mine: audio/wav txt: type: file diff --git a/system/config/system.yaml b/system/config/system.yaml index 27dcc5c27..a7c1ada23 100644 --- a/system/config/system.yaml +++ b/system/config/system.yaml @@ -5,7 +5,6 @@ home: pages: theme: antimatter # Default theme (defaults to "antimatter" theme) - markdown_extra: false # Enable support for Markdown Extra support (GFM by default) order: by: defaults # Order pages by "default", "alpha" or "date" dir: asc # Default ordering direction, "asc" or "desc" @@ -21,6 +20,14 @@ pages: events: page: true # Enable page level events twig: true # Enable twig level events + markdown: + extra: false # Enable support for Markdown Extra support (GFM by default) + auto_line_breaks: false # Enable automatic line breaks + auto_url_links: false # Enable automatic HTML links + escape_markup: false # Escape markup tags into entities + special_chars: # List of special characters to automatically convert to entities + '>': 'gt' + '<': 'lt' cache: enabled: true # Set to true to enable caching @@ -29,6 +36,7 @@ cache: driver: auto # One of: auto|file|apc|xcache|memcache|wincache prefix: 'g' # Cache prefix string (prevents cache conflicts) lifetime: 604800 # Lifetime of cached data in seconds (0 = infinite) + gzip: false # GZip compress the page output twig: cache: true # Set to true to enable twig caching diff --git a/system/defines.php b/system/defines.php index 473292fdf..81e161036 100644 --- a/system/defines.php +++ b/system/defines.php @@ -2,7 +2,7 @@ // Some standard defines define('GRAV', true); -define('GRAV_VERSION', '0.9.13'); +define('GRAV_VERSION', '0.9.14'); define('DS', '/'); // Directories and Paths @@ -17,14 +17,16 @@ define('ASSETS_DIR', ROOT_DIR . 'assets/'); define('CACHE_DIR', ROOT_DIR . 'cache/'); define('IMAGES_DIR', ROOT_DIR . 'images/'); define('LOG_DIR', ROOT_DIR .'logs/'); -define('VENDOR_DIR', ROOT_DIR .'vendor/'); -define('LIB_DIR', SYSTEM_DIR .'src/'); define('ACCOUNTS_DIR', USER_DIR .'accounts/'); -define('DATA_DIR', USER_DIR .'data/'); define('PAGES_DIR', USER_DIR .'pages/'); +// DEPRECATED: Do not use! +define('DATA_DIR', USER_DIR .'data/'); +define('LIB_DIR', SYSTEM_DIR .'src/'); define('PLUGINS_DIR', USER_DIR .'plugins/'); define('THEMES_DIR', USER_DIR .'themes/'); +define('VENDOR_DIR', ROOT_DIR .'vendor/'); +// END DEPRECATED // Some extensions define('CONTENT_EXT', '.md'); diff --git a/system/src/Grav/Common/Assets.php b/system/src/Grav/Common/Assets.php index 2270016ce..81856f504 100644 --- a/system/src/Grav/Common/Assets.php +++ b/system/src/Grav/Common/Assets.php @@ -234,8 +234,9 @@ class Assets $asset = $this->buildLocalLink($asset); } - if ($asset && !array_key_exists($asset, $this->css)) { - $this->css[$asset] = [ + $key = md5($asset); + if ($asset && !array_key_exists($key, $this->css)) { + $this->css[$key] = [ 'asset' => $asset, 'priority' => $priority, 'order' => count($this->css), @@ -272,8 +273,9 @@ class Assets $asset = $this->buildLocalLink($asset); } - if ($asset && !array_key_exists($asset, $this->js)) { - $this->js[$asset] = [ + $key = md5($asset); + if ($asset && !array_key_exists($key, $this->js)) { + $this->js[$key] = [ 'asset' => $asset, 'priority' => $priority, 'order' => count($this->js), @@ -297,9 +299,13 @@ class Assets */ public function addInlineCss($asset, $priority = 10) { - - if (is_string($asset) && !in_array($asset, $this->inline_css)) { - $this->inline_css[] = $asset; + $key = md5($asset); + if (is_string($asset) && !array_key_exists($key, $this->inline_css)) { + $this->inline_css[$key] = [ + 'priority' => $priority, + 'order' => count($this->inline_css), + 'asset' => $asset + ]; } return $this; @@ -312,14 +318,19 @@ class Assets * For adding chunks of string-based inline JS * * @param mixed $asset + * @param int $priority the priority, bigger comes first * * @return $this */ - public function addInlineJs($asset) + public function addInlineJs($asset, $priority = 10) { - - if (is_string($asset) && !in_array($asset, $this->inline_js)) { - $this->inline_js[] = $asset; + $key = md5($asset); + if (is_string($asset) && !array_key_exists($key, $this->inline_js)) { + $this->inline_js[$key] = [ + 'priority' => $priority, + 'order' => count($this->inline_js), + 'asset' => $asset + ]; } return $this; @@ -346,8 +357,16 @@ class Assets } return $a['priority'] - $b['priority']; }); + + usort($this->inline_css, function ($a, $b) { + if ($a['priority'] == $b['priority']) { + return $b['order'] - $a['order']; + } + return $a['priority'] - $b['priority']; + }); } $this->css = array_reverse($this->css); + $this->inline_css = array_reverse($this->inline_css); $attributes = $this->attributes(array_merge(['type' => 'text/css', 'rel' => 'stylesheet'], $attributes)); @@ -368,7 +387,7 @@ class Assets if (count($this->inline_css) > 0) { $output .= "\n"; } @@ -397,7 +416,16 @@ class Assets } return $a['priority'] - $b['priority']; }); + + usort($this->inline_js, function ($a, $b) { + if ($a['priority'] == $b['priority']) { + return $b['order'] - $a['order']; + } + return $a['priority'] - $b['priority']; + }); + $this->js = array_reverse($this->js); + $this->inline_js = array_reverse($this->inline_js); $attributes = $this->attributes(array_merge(['type' => 'text/javascript'], $attributes)); @@ -417,7 +445,7 @@ class Assets if (count($this->inline_js) > 0) { $output .= "\n"; } diff --git a/system/src/Grav/Common/Config/Config.php b/system/src/Grav/Common/Config/Config.php index 7cc1cce51..14cfffe61 100644 --- a/system/src/Grav/Common/Config/Config.php +++ b/system/src/Grav/Common/Config/Config.php @@ -168,6 +168,8 @@ class Config extends Data $this->loadCompiledBlueprints($this->blueprintLookup, $this->pluginLookup, 'master'); $this->loadCompiledConfig($this->configLookup, $this->pluginLookup, 'master'); + + $this->initializeLocator($locator); } public function checksum() @@ -331,4 +333,47 @@ class Config extends Data $this->join($name, $file->content(), '/'); } } + + /** + * Initialize resource locator by using the configuration. + * + * @param UniformResourceLocator $locator + */ + public function initializeLocator(UniformResourceLocator $locator) + { + $locator->reset(); + + $schemes = (array) $this->get('streams.schemes', []); + + foreach ($schemes as $scheme => $config) { + if (isset($config['paths'])) { + $locator->addPath($scheme, '', $config['paths']); + } + if (isset($config['prefixes'])) { + foreach ($config['prefixes'] as $prefix => $paths) { + $locator->addPath($scheme, $prefix, $paths); + } + } + } + } + + /** + * Get available streams and their types from the configuration. + * + * @return array + */ + public function getStreams() + { + $schemes = []; + foreach ((array) $this->get('streams.schemes') as $scheme => $config) { + $type = !empty($config['type']) ? $config['type'] : 'ReadOnlyStream'; + if ($type[0] != '\\') { + $type = '\\RocketTheme\\Toolbox\\StreamWrapper\\' . $type; + } + + $schemes[$scheme] = $type; + } + + return $schemes; + } } diff --git a/system/src/Grav/Common/Filesystem/Folder.php b/system/src/Grav/Common/Filesystem/Folder.php index c54e019fe..79eb5ada8 100644 --- a/system/src/Grav/Common/Filesystem/Folder.php +++ b/system/src/Grav/Common/Filesystem/Folder.php @@ -33,17 +33,41 @@ abstract class Folder return $last_modified; } - - public static function getRelativePath($to, $from = ROOT_DIR) + /** + * Get relative path between target and base path. If path isn't relative, return full path. + * + * @param string $path + * @param string $base + * @return string + */ + public static function getRelativePath($path, $base = GRAV_ROOT) { - $from = preg_replace('![\\|/]+!', '/', $from); - $to = preg_replace('![\\|/]+!', '/', $to); - if (strpos($to, $from) === 0) { - $to = substr($to, strlen($from)); + if ($base) { + $base = preg_replace('![\\|/]+!', '/', $base); + $path = preg_replace('![\\|/]+!', '/', $path); + if (strpos($path, $base) === 0) { + $path = ltrim(substr($path, strlen($base)), '/'); + } } - return $to; + return $path; } + + /** + * Shift first directory out of the path. + * + * @param string $path + * @return string + */ + public static function shift(&$path) + { + $parts = explode('/', trim($path, '/'), 2); + $result = array_shift($parts); + $path = array_shift($parts); + + return $result ?: null; + } + /** * Recursively find the last modified time under given path by file. * @@ -208,8 +232,9 @@ abstract class Folder /** * Recursively delete directory from filesystem. * - * @param string $target + * @param string $target * @throws \RuntimeException + * @return bool */ public static function delete($target) { diff --git a/system/src/Grav/Common/Grav.php b/system/src/Grav/Common/Grav.php index 5f731d742..aa5ef997c 100644 --- a/system/src/Grav/Common/Grav.php +++ b/system/src/Grav/Common/Grav.php @@ -1,6 +1,7 @@ dispatch($c['uri']->route()); + + // If base URI is set, we want to remove it from the URL. + $path = '/' . ltrim(Folder::getRelativePath($c['uri']->route(), $pages->base()), '/'); + + $page = $pages->dispatch($path); if (!$page || !$page->routable()) { // special case where a media file is requested if (!$page) { - $path_parts = pathinfo($c['uri']->route()); + $path_parts = pathinfo($path); + $page = $c['pages']->dispatch($path_parts['dirname']); if ($page) { $media = $page->media()->all(); @@ -164,6 +170,10 @@ class Grav extends Container { // Use output buffering to prevent headers from being sent too early. ob_start(); + if ($this['config']->get('system.cache.gzip')) { + ob_start('ob_gzhandler'); + } + /** @var Debugger $debugger */ $debugger = $this['debugger']; @@ -319,12 +329,21 @@ class Grav extends Container $this['session']->close(); } - header('Content-length: ' . ob_get_length()); + if ($this['config']->get('system.cache.gzip')) { + ob_end_flush(); // gzhandler buffer + } + + header('Content-Length: ' . ob_get_length()); header("Connection: close\r\n"); - ob_end_flush(); + ob_end_flush(); // regular buffer ob_flush(); flush(); + + if (function_exists('fastcgi_finish_request')) { + @fastcgi_finish_request(); + } + } $this->fireEvent('onShutdown'); diff --git a/system/src/Grav/Common/Markdown/Markdown.php b/system/src/Grav/Common/Markdown/Markdown.php deleted file mode 100644 index 31b8bd815..000000000 --- a/system/src/Grav/Common/Markdown/Markdown.php +++ /dev/null @@ -1,14 +0,0 @@ -page = $page; - $this->BlockTypes['{'] [] = "TwigTag"; - } - -} diff --git a/system/src/Grav/Common/Markdown/MarkdownExtra.php b/system/src/Grav/Common/Markdown/MarkdownExtra.php deleted file mode 100644 index 5bdef3b94..000000000 --- a/system/src/Grav/Common/Markdown/MarkdownExtra.php +++ /dev/null @@ -1,14 +0,0 @@ -page = $page; - $this->BlockTypes['{'] [] = "TwigTag"; - } -} diff --git a/system/src/Grav/Common/Markdown/Parsedown.php b/system/src/Grav/Common/Markdown/Parsedown.php new file mode 100644 index 000000000..1db2d1707 --- /dev/null +++ b/system/src/Grav/Common/Markdown/Parsedown.php @@ -0,0 +1,13 @@ +init($page); + } + +} diff --git a/system/src/Grav/Common/Markdown/ParsedownExtra.php b/system/src/Grav/Common/Markdown/ParsedownExtra.php new file mode 100644 index 000000000..da20ca1e0 --- /dev/null +++ b/system/src/Grav/Common/Markdown/ParsedownExtra.php @@ -0,0 +1,13 @@ +init($page); + } +} diff --git a/system/src/Grav/Common/Markdown/MarkdownGravLinkTrait.php b/system/src/Grav/Common/Markdown/ParsedownGravTrait.php similarity index 53% rename from system/src/Grav/Common/Markdown/MarkdownGravLinkTrait.php rename to system/src/Grav/Common/Markdown/ParsedownGravTrait.php index 6242ef01c..2cd116cbb 100644 --- a/system/src/Grav/Common/Markdown/MarkdownGravLinkTrait.php +++ b/system/src/Grav/Common/Markdown/ParsedownGravTrait.php @@ -10,60 +10,109 @@ use Grav\Common\Uri; /** * A trait to add some custom processing to the identifyLink() method in Parsedown and ParsedownExtra */ -trait MarkdownGravLinkTrait +trait ParsedownGravTrait { use GravTrait; + protected $page; + protected $base_url; + protected $pages_dir; + protected $special_chars; + + protected $twig_link_regex = '/\!*\[(?:.*)\]\(([{{|{%|{#].*[#}|%}|}}])\)/'; + + /** + * Initialiazation function to setup key variables needed by the MarkdownGravLinkTrait + * + * @param $page + */ + protected function init($page) + { + $this->page = $page; + $this->BlockTypes['{'] [] = "TwigTag"; + $this->base_url = rtrim(self::$grav['base_url'] . self::$grav['pages']->base(), '/'); + $this->pages_dir = self::$grav['locator']->findResource('page://'); + $this->special_chars = array('>' => 'gt', '<' => 'lt', '"' => 'quot'); + } + + /** + * Setter for special chars + * + * @param $special_chars + * + * @return $this + */ + function setSpecialChars($special_chars) + { + $this->special_chars = $special_chars; + + return $this; + } /** * Ensure Twig tags are treated as block level items with no

tags */ - protected function identifyTwigTag($Line) + protected function blockTwigTag($Line) { if (preg_match('/[{%|{{|{#].*[#}|}}|%}]/', $Line['body'], $matches)) { $Block = array( - 'element' => $Line['body'], + 'markup' => $Line['body'], ); return $Block; } } - protected function identifyLink($Excerpt) + protected function inlineSpecialCharacter($Excerpt) { - /** @var Config $config */ - $config = self::$grav['config']; - - // Run the parent method to get the actual results - $Excerpt = parent::identifyLink($Excerpt); - $actions = array(); - $this->base_url = self::$grav['base_url']; - - // if this is a link - if (isset($Excerpt['element']['attributes']['href'])) { - - $url = parse_url(htmlspecialchars_decode($Excerpt['element']['attributes']['href'])); - - // if there is no scheme, the file is local - if (!isset($url['scheme'])) { - - // convert the URl is required - $Excerpt['element']['attributes']['href'] = $this->convertUrl(Uri::build_url($url)); - } + if ($Excerpt['text'][0] === '&' and ! preg_match('/^&#?\w+;/', $Excerpt['text'])) { + return array( + 'markup' => '&', + 'extent' => 1, + ); } - // if this is an image - if (isset($Excerpt['element']['attributes']['src'])) { + if (isset($this->special_chars[$Excerpt['text'][0]])) { + return array( + 'markup' => '&'.$this->special_chars[$Excerpt['text'][0]].';', + 'extent' => 1, + ); + } + } - $alt = isset($Excerpt['element']['attributes']['alt']) ? $Excerpt['element']['attributes']['alt'] : ''; - $title = isset($Excerpt['element']['attributes']['title']) ? $Excerpt['element']['attributes']['title'] : ''; + protected function inlineImage($excerpt) + { + if (preg_match($this->twig_link_regex, $excerpt['text'], $matches)) { + $excerpt['text'] = str_replace($matches[1], '/', $excerpt['text']); + $excerpt = parent::inlineImage($excerpt); + $excerpt['element']['attributes']['src'] = $matches[1]; + $excerpt['extent'] = $excerpt['extent'] + strlen($matches[1]) - 1; + return $excerpt; + } else { + $excerpt = parent::inlineImage($excerpt); + } + + $actions = array(); + + // if this is an image + if (isset($excerpt['element']['attributes']['src'])) { + + $alt = $excerpt['element']['attributes']['alt'] ?: ''; + $title = $excerpt['element']['attributes']['title'] ?: ''; //get the url and parse it - $url = parse_url(htmlspecialchars_decode($Excerpt['element']['attributes']['src'])); + $url = parse_url(htmlspecialchars_decode($excerpt['element']['attributes']['src'])); + + //get back to current page if possible // if there is no host set but there is a path, the file is local if (!isset($url['host']) && isset($url['path'])) { // get the media objects for this page $media = $this->page->media(); + // get the local path to page media if possible + if (strpos($url['path'], $this->page->url()) !== false) { + $url['path'] = ltrim(str_replace($this->page->url(), '', $url['path']), '/'); + } + // if there is a media file that matches the path referenced.. if (isset($media->images()[$url['path']])) { // get the medium object @@ -92,10 +141,10 @@ trait MarkdownGravLinkTrait // set the src element with the new generated url if (!isset($actions['lightbox']) && !is_array($src)) { - $Excerpt['element']['attributes']['src'] = $src; + $excerpt['element']['attributes']['src'] = $src; } else { // Create the custom lightbox element - $Element = array( + $element = array( 'name' => 'a', 'attributes' => array('rel' => $src['a_rel'], 'href' => $src['a_url']), 'handler' => 'element', @@ -106,20 +155,50 @@ trait MarkdownGravLinkTrait ); // Set any custom classes on the lightbox element - if (isset($Excerpt['element']['attributes']['class'])) { - $Element['attributes']['class'] = $Excerpt['element']['attributes']['class']; + if (isset($excerpt['element']['attributes']['class'])) { + $element['attributes']['class'] = $excerpt['element']['attributes']['class']; } // Set the lightbox element on the Excerpt - $Excerpt['element'] = $Element; + $excerpt['element'] = $element; } } else { // not a current page media file, see if it needs converting to relative - $Excerpt['element']['attributes']['src'] = $this->convertUrl(Uri::build_url($url)); + $excerpt['element']['attributes']['src'] = $this->convertUrl(Uri::build_url($url)); } } } - return $Excerpt; + + return $excerpt; + } + + protected function inlineLink($excerpt) + { + // do some trickery to get around Parsedown requirement for valid URL if its Twig in there + if (preg_match($this->twig_link_regex, $excerpt['text'], $matches)) { + $excerpt['text'] = str_replace($matches[1], '/', $excerpt['text']); + $excerpt = parent::inlineLink($excerpt); + $excerpt['element']['attributes']['href'] = $matches[1]; + $excerpt['extent'] = $excerpt['extent'] + strlen($matches[1]) - 1; + return $excerpt; + } else { + $excerpt = parent::inlineLink($excerpt); + } + + // if this is a link + if (isset($excerpt['element']['attributes']['href'])) { + + $url = parse_url(htmlspecialchars_decode($excerpt['element']['attributes']['href'])); + + // if there is no scheme, the file is local + if (!isset($url['scheme'])) { + + // convert the URl is required + $excerpt['element']['attributes']['href'] = $this->convertUrl(Uri::build_url($url)); + } + } + + return $excerpt; } /** @@ -129,18 +208,18 @@ trait MarkdownGravLinkTrait */ protected function convertUrl($markdown_url) { - // if absolue and starts with a base_url move on + // if absolute and starts with a base_url move on if ($this->base_url != '' && strpos($markdown_url, $this->base_url) === 0) { $new_url = $markdown_url; - // if its absolute with / + // if its absolute and starts with / } elseif (strpos($markdown_url, '/') === 0) { - $new_url = rtrim($this->base_url, '/') . $markdown_url; + $new_url = $this->base_url . $markdown_url; } else { - $relative_path = rtrim($this->base_url, '/') . $this->page->route(); + $relative_path = $this->base_url . $this->page->route(); // If this is a 'real' filepath clean it up - if (file_exists($this->page->path().'/'.parse_url($markdown_url, PHP_URL_PATH))) { - $relative_path = rtrim($this->base_url, '/') . preg_replace('/\/([\d]+.)/', '/', str_replace(PAGES_DIR, '/', $this->page->path())); + if (file_exists($this->page->path() . '/' . parse_url($markdown_url, PHP_URL_PATH))) { + $relative_path = $this->base_url . preg_replace('/\/([\d]+.)/', '/', str_replace($this->pages_dir, '', $this->page->path())); $markdown_url = preg_replace('/^([\d]+.)/', '', preg_replace('/\/([\d]+.)/', '/', trim(preg_replace('/[^\/]+(\.md$)/', '', $markdown_url), '/'))); } diff --git a/system/src/Grav/Common/Page/Collection.php b/system/src/Grav/Common/Page/Collection.php index 5dd03ceb0..59302b781 100644 --- a/system/src/Grav/Common/Page/Collection.php +++ b/system/src/Grav/Common/Page/Collection.php @@ -226,15 +226,15 @@ class Collection extends Iterator $start = strtotime($startDate); $end = $endDate ? strtotime($endDate) : strtotime("now +1000 years"); - $daterange = []; + $date_range = []; foreach ($this->items as $path => $slug) { $page = $this->pages->get($path); if ($page->date() > $start && $page->date() < $end) { - $daterange[$path] = $slug; + $date_range[$path] = $slug; } } - $this->items = $daterange; + $this->items = $date_range; return $this; } diff --git a/system/src/Grav/Common/Page/Media.php b/system/src/Grav/Common/Page/Media.php index ca175c7b4..97ac69962 100644 --- a/system/src/Grav/Common/Page/Media.php +++ b/system/src/Grav/Common/Page/Media.php @@ -23,6 +23,7 @@ class Media extends Getters protected $instances = array(); protected $images = array(); protected $videos = array(); + protected $audios = array(); protected $files = array(); /** @@ -155,6 +156,17 @@ class Media extends Getters return $this->videos; } + /** + * Get a list of all audio media. + * + * @return array|Medium[] + */ + public function audios() + { + ksort($this->audios, SORT_NATURAL | SORT_FLAG_CASE); + return $this->audios; + } + /** * Get a list of all file media. * @@ -179,6 +191,9 @@ class Media extends Getters case 'video': $this->videos[$file->filename] = $file; break; + case 'audio': + $this->audios[$file->filename] = $file; + break; default: $this->files[$file->filename] = $file; } diff --git a/system/src/Grav/Common/Page/Page.php b/system/src/Grav/Common/Page/Page.php index 9909c2568..feeb449a8 100644 --- a/system/src/Grav/Common/Page/Page.php +++ b/system/src/Grav/Common/Page/Page.php @@ -10,8 +10,8 @@ use Grav\Common\Twig; use Grav\Common\Uri; use Grav\Common\Grav; use Grav\Common\Taxonomy; -use Grav\Common\Markdown\Markdown; -use Grav\Common\Markdown\MarkdownExtra; +use Grav\Common\Markdown\Parsedown; +use Grav\Common\Markdown\ParsedownExtra; use Grav\Common\Data\Blueprint; use RocketTheme\Toolbox\Event\Event; use RocketTheme\Toolbox\File\MarkdownFile; @@ -122,11 +122,9 @@ class Page $this->visible(); $this->modularTwig($this->slug[0] == '_'); - // Handle publishing dates - $config = self::$grav['config']; - - if ($config->get('system.pages.publish_dates')) { - + // Handle publishing dates if no explict published option set + if (self::$grav['config']->get('system.pages.publish_dates') && !isset($this->header->published)) { + // unpublish if required, if not clear cache right before page should be unpublished if ($this->unpublishDate()) { if ($this->unpublishDate() < time()) { $this->published(false); @@ -135,14 +133,13 @@ class Page self::$grav['cache']->setLifeTime($this->unpublishDate()); } } - + // publish if required, if not clear cache right before page is published if ($this->publishDate() != $this->modified() && $this->publishDate() > time()) { $this->published(false); self::$grav['cache']->setLifeTime($this->publishDate()); } } $this->published(); - } /** @@ -349,38 +346,50 @@ class Page $cache_id = md5('page'.$this->id()); $this->content = $cache->fetch($cache_id); - $update_cache = false; - if ($this->content === false) { - // Process Markdown - $this->content = $this->processMarkdown(); - $update_cache = true; + $process_markdown = $this->shouldProcess('markdown'); + $process_twig = $this->shouldProcess('twig'); + $cache_twig = isset($this->header->cache_enable) ? $this->header->cache_enable : true; + $twig_first = isset($this->header->twig_first) ? $this->header->twig_first : false; + $twig_already_processed = false; + + // if no cached-content run everything + if ($this->content == false) { + + $this->content = $this->raw_content; + self::$grav->fireEvent('onPageContentRaw', new Event(['page' => $this])); + + if ($twig_first) { + if ($process_twig) { + $this->processTwig(); + $twig_already_processed = true; + } + if ($process_markdown) { + $this->processMarkdown(); + } + if ($cache_twig) { + $this->cachePageContent(); + } + } else { + if ($process_markdown) { + $this->processMarkdown(); + } + if (!$cache_twig) { + $this->cachePageContent(); + } + if ($process_twig) { + $this->processTwig(); + $twig_already_processed = true; + } + if ($cache_twig) { + $this->cachePageContent(); + } + } + // content cached, but twig cache off } - // Process Twig if enabled - if ($this->shouldProcess('twig')) { - - // Always process twig if caching in the page is disabled - $process_twig = (isset($this->header->cache_enable) && !$this->header->cache_enable); - - // Do we want to cache markdown, but process twig in each page? - if ($update_cache && $process_twig) { - $cache->save($cache_id, $this->content); - $update_cache = false; - } - - // Do we need to process twig this time? - if ($update_cache || $process_twig) { - /** @var Twig $twig */ - $twig = self::$grav['twig']; - $this->content = $twig->processPage($this, $this->content); - } - } - - // Cache the whole page, including processed content - if ($update_cache) { - // Process any post-processing but pre-caching functionality - self::$grav->fireEvent('onPageContentProcessed', new Event(['page' => $this])); - $cache->save($cache_id, $this->content); + // only markdown content cached, process twig if required and not already processed + if ($process_twig && !$cache_twig && !$twig_already_processed) { + $this->processTwig(); } // Handle summary divider @@ -395,6 +404,61 @@ class Page return $this->content; } + /** + * Process the Markdown content. Uses Parsedown or Parsedown Extra depending on configuration + */ + protected function processMarkdown() + { + /** @var Config $config */ + $config = self::$grav['config']; + + $defaults = (array) $config->get('system.pages.markdown'); + if (isset($this->header()->markdown)) { + $defaults = array_merge($defaults, $this->header()->markdown); + } + + // pages.markdown_extra is deprecated, but still check it... + if (isset($this->markdown_extra) || $config->get('system.pages.markdown_extra') !== null) { + $defaults['extra'] = $this->markdown_extra; + } + + // Initialize the preferred variant of Parsedown + if ($defaults['extra']) { + $parsedown = new ParsedownExtra($this); + } else { + $parsedown = new Parsedown($this); + } + + $parsedown->setBreaksEnabled($defaults['auto_line_breaks']); + $parsedown->setUrlsLinked($defaults['auto_url_links']); + $parsedown->setMarkupEscaped($defaults['escape_markup']); + $parsedown->setSpecialChars($defaults['special_chars']); + + $this->content = $parsedown->text($this->content); + } + + + /** + * Process the Twig page content. + */ + private function processTwig() + { + $twig = self::$grav['twig']; + $this->content = $twig->processPage($this, $this->content); + } + + /** + * Fires the onPageContentProcessed event, and caches the page content using a unique ID for the page + */ + private function cachePageContent() + { + $cache = self::$grav['cache']; + $cache_id = md5('page'.$this->id()); + + self::$grav->fireEvent('onPageContentProcessed', new Event(['page' => $this])); + $cache->save($cache_id, $this->content); + } + /** * Needed by the onPageContentProcessed event to get the raw page content * @@ -453,6 +517,9 @@ class Page if ($name == 'media.image') { return $this->media()->images(); } + if ($name == 'media.audio') { + return $this->media()->audios(); + } $path = explode('.', $name); $scope = array_shift($path); @@ -848,9 +915,16 @@ class Page /** * Function to merge page metadata tags and build an array of Metadata objects * that can then be rendered in the page. + * + * @param array $var an Array of metadata values to set + * @return array an Array of metadata values for the page */ - public function metadata() + public function metadata($var = null) { + if ($var !== null) { + $this->metadata = (array) $var; + } + // if not metadata yet, process it. if (null === $this->metadata) { @@ -879,14 +953,14 @@ class Page if (is_array($value)) { foreach ($value as $property => $prop_value) { $prop_key = $key.":".$property; - $this->metadata[$prop_key] = array('property'=>$prop_key, 'content'=>$prop_value); + $this->metadata[$prop_key] = array('property'=>$prop_key, 'content'=>htmlspecialchars($prop_value, ENT_QUOTES)); } // If it this is a standard meta data type } else { if (in_array($key, $header_tag_http_equivs)) { - $this->metadata[$key] = array('http_equiv'=>$key, 'content'=>$value); + $this->metadata[$key] = array('http_equiv'=>$key, 'content'=>htmlspecialchars($value, ENT_QUOTES)); } else { - $this->metadata[$key] = array('name'=>$key, 'content'=>$value); + $this->metadata[$key] = array('name'=>$key, 'content'=>htmlspecialchars($value, ENT_QUOTES)); } } } @@ -966,9 +1040,13 @@ class Page */ public function url($include_host = false) { + /** @var Pages $pages */ + $pages = self::$grav['pages']; + /** @var Uri $uri */ $uri = self::$grav['uri']; - $rootUrl = $uri->rootUrl($include_host); + + $rootUrl = $uri->rootUrl($include_host) . $pages->base(); $url = $rootUrl.'/'.trim($this->route(), '/'); // trim trailing / if not root @@ -1209,6 +1287,7 @@ class Page $this->modular_twig = (bool) $var; if ($var) { $this->process['twig'] = true; + $this->visible(false); } } return $this->modular_twig; @@ -1382,14 +1461,16 @@ class Page * Helper method to return a page. * * @param string $url the url of the page - * @return Page page you were looking for if it exists + * @param bool $all + * + * @return \Grav\Common\Page\Page page you were looking for if it exists * @deprecated */ - public function find($url) + public function find($url, $all = false) { /** @var Pages $pages */ $pages = self::$grav['pages']; - return $pages->dispatch($url); + return $pages->dispatch($url, $all); } /** @@ -1585,53 +1666,6 @@ class Page return $file && $file->exists(); } - /** - * Process the Markdown if processing is enabled for it. If not, process as 'raw' which simply strips the - * header YAML from the raw, and sends back the content portion. i.e. the bit below the header. - * - * @return string the content for the page - */ - protected function processMarkdown() - { - // Process Markdown if required - $process_method = $this->shouldProcess('markdown') ? 'parseMarkdownContent' : 'rawContent'; - $content = $this->$process_method($this->raw_content); - - return $content; - } - - /** - * Process the raw content. Basically just strips the headers out and returns the rest. - * - * @param string $content Input raw content - * @return string Output content after headers have been stripped - */ - protected function rawContent($content) - { - return $content; - } - - /** - * Process the Markdown content. This strips the headers, the process the resulting content as Markdown. - * - * @param string $content Input raw content - * @return string Output content that has been processed as Markdown - */ - protected function parseMarkdownContent($content) - { - /** @var Config $config */ - $config = self::$grav['config']; - - // get the appropriate setting for markdown extra - if (isset($this->markdown_extra) ? $this->markdown_extra : $config->get('system.pages.markdown_extra')) { - $parsedown = new MarkdownExtra($this); - } else { - $parsedown = new Markdown($this); - } - $content = $parsedown->text($content); - return $content; - } - /** * Cleans the path. * diff --git a/system/src/Grav/Common/Page/Pages.php b/system/src/Grav/Common/Page/Pages.php index 0f8c0f4ac..8d8966d62 100644 --- a/system/src/Grav/Common/Page/Pages.php +++ b/system/src/Grav/Common/Page/Pages.php @@ -10,6 +10,7 @@ use Grav\Common\Data\Blueprint; use Grav\Common\Data\Blueprints; use Grav\Common\Filesystem\Folder; use RocketTheme\Toolbox\Event\Event; +use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator; /** * GravPages is the class that is the entry point into the hierarchy of pages @@ -34,6 +35,11 @@ class Pages */ protected $children; + /** + * @var string + */ + protected $base; + /** * @var array|string[] */ @@ -67,6 +73,23 @@ class Pages public function __construct(Grav $c) { $this->grav = $c; + $this->base = ''; + } + + /** + * Get or set base path for the pages. + * + * @param string $path + * @return string + */ + public function base($path = null) + { + if ($path !== null) { + $path = trim($path, '/'); + $this->base = $path ? '/' . $path : null; + } + + return $this->base; } /** @@ -236,8 +259,6 @@ class Pages // Fetch page if there's a defined route to it. $page = isset($this->routes[$url]) ? $this->get($this->routes[$url]) : null; - - // If the page cannot be reached, look into site wide redirects, routes + wildcards if (!$all && (!$page || !$page->routable())) { /** @var Config $config */ @@ -278,7 +299,10 @@ class Pages */ public function root() { - return $this->instances[rtrim(PAGES_DIR, DS)]; + /** @var UniformResourceLocator $locator */ + $locator = $this->grav['locator']; + + return $this->instances[rtrim($locator->findResource('page://'), DS)]; } /** @@ -408,6 +432,10 @@ class Pages /** @var Config $config */ $config = $this->grav['config']; + /** @var UniformResourceLocator $locator */ + $locator = $this->grav['locator']; + $pagesDir = $locator->findResource('page://'); + if ($config->get('system.cache.enabled')) { /** @var Cache $cache */ $cache = $this->grav['cache']; @@ -421,10 +449,10 @@ class Pages $last_modified = 0; break; case 'folder': - $last_modified = Folder::lastModifiedFolder(PAGES_DIR); + $last_modified = Folder::lastModifiedFolder($pagesDir); break; default: - $last_modified = Folder::lastModifiedFile(PAGES_DIR); + $last_modified = Folder::lastModifiedFile($pagesDir); } $page_cache_id = md5(USER_DIR.$last_modified.$config->checksum()); @@ -432,7 +460,7 @@ class Pages list($this->instances, $this->routes, $this->children, $taxonomy_map, $this->sort) = $cache->fetch($page_cache_id); if (!$this->instances) { $this->grav['debugger']->addMessage('Page cache missed, rebuilding pages..'); - $this->recurse(); + $this->recurse($pagesDir); $this->buildRoutes(); // save pages, routes, taxonomy, and sort to cache @@ -446,7 +474,7 @@ class Pages $taxonomy->taxonomy($taxonomy_map); } } else { - $this->recurse(); + $this->recurse($pagesDir); $this->buildRoutes(); } } @@ -460,7 +488,7 @@ class Pages * @throws \RuntimeException * @internal */ - protected function recurse($directory = PAGES_DIR, Page &$parent = null) + protected function recurse($directory, Page &$parent = null) { $directory = rtrim($directory, DS); $iterator = new \DirectoryIterator($directory); diff --git a/system/src/Grav/Common/Service/ConfigServiceProvider.php b/system/src/Grav/Common/Service/ConfigServiceProvider.php index 0945ce038..ce6b76fa0 100644 --- a/system/src/Grav/Common/Service/ConfigServiceProvider.php +++ b/system/src/Grav/Common/Service/ConfigServiceProvider.php @@ -18,11 +18,17 @@ use RocketTheme\Toolbox\Blueprints\Blueprints; class ConfigServiceProvider implements ServiceProviderInterface { private $environment; + private $setup; public function register(Container $container) { $self = $this; + // Pre-load setup.php as it contains our initial configuration. + $file = GRAV_ROOT . '/setup.php'; + $this->setup = is_file($file) ? (array) include $file : []; + $this->environment = isset($this->setup['environment']) ? $this->setup['environment'] : null; + $container['blueprints'] = function ($c) use ($self) { return $self->loadMasterBlueprints($c); }; @@ -45,9 +51,7 @@ class ConfigServiceProvider implements ServiceProviderInterface } if (!isset($config)) { - $file = GRAV_ROOT . '/setup.php'; - $data = is_file($file) ? (array) include $file : []; - $config = new Config($data, $container, $environment); + $config = new Config($this->setup, $container, $environment); } return $config; diff --git a/system/src/Grav/Common/Service/StreamsServiceProvider.php b/system/src/Grav/Common/Service/StreamsServiceProvider.php index 7cfa187eb..ae251cb72 100644 --- a/system/src/Grav/Common/Service/StreamsServiceProvider.php +++ b/system/src/Grav/Common/Service/StreamsServiceProvider.php @@ -11,52 +11,32 @@ use RocketTheme\Toolbox\StreamWrapper\StreamBuilder; class StreamsServiceProvider implements ServiceProviderInterface { - protected $schemes = []; - public function register(Container $container) { $self = $this; $container['locator'] = function($c) use ($self) { $locator = new UniformResourceLocator(ROOT_DIR); - $self->init($c, $locator); + + /** @var Config $config */ + $config = $c['config']; + $config->initializeLocator($locator); return $locator; }; $container['streams'] = function($c) use ($self) { + /** @var Config $config */ + $config = $c['config']; + + /** @var UniformResourceLocator $locator */ $locator = $c['locator']; // Set locator to both streams. Stream::setLocator($locator); ReadOnlyStream::setLocator($locator); - return new StreamBuilder($this->schemes); + return new StreamBuilder($config->getStreams($c)); }; } - - protected function init(Container $container, UniformResourceLocator $locator) - { - /** @var Config $config */ - $config = $container['config']; - $schemes = (array) $config->get('streams.schemes', []); - - foreach ($schemes as $scheme => $config) { - if (isset($config['paths'])) { - $locator->addPath($scheme, '', $config['paths']); - } - if (isset($config['prefixes'])) { - foreach ($config['prefixes'] as $prefix => $paths) { - $locator->addPath($scheme, $prefix, $paths); - } - } - - $type = !empty($config['type']) ? $config['type'] : 'ReadOnlyStream'; - if ($type[0] != '\\') { - $type = '\\RocketTheme\\Toolbox\\StreamWrapper\\' . $type; - } - - $this->schemes[$scheme] = $type; - } - } } diff --git a/system/src/Grav/Common/Twig.php b/system/src/Grav/Common/Twig.php index 081f7e6d4..0fbdb81fc 100644 --- a/system/src/Grav/Common/Twig.php +++ b/system/src/Grav/Common/Twig.php @@ -125,9 +125,6 @@ class Twig $this->grav->fireEvent('onTwigExtensions'); - $theme = $config->get('system.pages.theme'); - $themeUrl = $this->grav['base_url'] .'/'. USER_PATH . basename(THEMES_DIR) .'/'. $theme; - // Set some standard variables for twig $this->twig_vars = array( 'grav' => $this->grav, @@ -138,7 +135,7 @@ class Twig 'base_url_absolute' => $this->grav['base_url_absolute'], 'base_url_relative' => $this->grav['base_url_relative'], 'theme_dir' => $locator->findResource('theme://'), - 'theme_url' => $themeUrl, + 'theme_url' => $this->grav['base_url'] .'/'. $locator->findResource('theme://', false), 'site' => $config->get('site'), 'assets' => $this->grav['assets'], 'taxonomy' => $this->grav['taxonomy'], diff --git a/system/src/Grav/Common/TwigExtension.php b/system/src/Grav/Common/TwigExtension.php index 2d1a2a636..65a546900 100644 --- a/system/src/Grav/Common/TwigExtension.php +++ b/system/src/Grav/Common/TwigExtension.php @@ -331,19 +331,32 @@ class TwigExtension extends \Twig_Extension /** * Return URL to the resource. * - * @param string $input - * @param bool $domain - * @return string + * @example {{ url('theme://images/logo.png')|default('http://www.placehold.it/150x100/f4f4f4') }} + * + * @param string $input Resource to be located. + * @param bool $domain True to include domain name. + * @return string|null Returns url to the resource or null if resource was not found. */ public function urlFunc($input, $domain = false) { - /** @var UniformResourceLocator $locator */ - $locator = $this->grav['locator']; + if (!trim((string) $input)) { + return false; + } + + if (strpos((string) $input, '://')) { + /** @var UniformResourceLocator $locator */ + $locator = $this->grav['locator']; + + // Get relative path to the resource (or false if not found). + $resource = $locator->findResource((string) $input, false); + } else { + $resource = (string) $input; + } /** @var Uri $uri */ $uri = $this->grav['uri']; - return $uri->rootUrl($domain) .'/'. $locator->findResource($input, false); + return $resource ? rtrim($uri->rootUrl($domain), '/') . '/' . $resource : null; } /** diff --git a/system/src/Grav/Common/User/User.php b/system/src/Grav/Common/User/User.php index e9626eda4..8da0896fe 100644 --- a/system/src/Grav/Common/User/User.php +++ b/system/src/Grav/Common/User/User.php @@ -4,6 +4,7 @@ namespace Grav\Common\User; use Grav\Common\Data\Blueprints; use Grav\Common\Data\Data; use Grav\Common\File\CompiledYamlFile; +use Grav\Common\GravTrait; /** * User object @@ -13,6 +14,8 @@ use Grav\Common\File\CompiledYamlFile; */ class User extends Data { + use GravTrait; + /** * Load user account. * @@ -23,10 +26,13 @@ class User extends Data */ public static function load($username) { + $locator = self::$grav['locator']; + // FIXME: validate directory name $blueprints = new Blueprints('blueprints://user'); $blueprint = $blueprints->get('account'); - $file = CompiledYamlFile::instance(ACCOUNTS_DIR . $username . YAML_EXT); + $file_path = $locator->findResource('account://' . $username . YAML_EXT); + $file = CompiledYamlFile::instance($file_path); $content = $file->content(); if (!isset($content['username'])) { $content['username'] = $username; diff --git a/system/src/Grav/Console/Cli/CleanCommand.php b/system/src/Grav/Console/Cli/CleanCommand.php index 13df85c28..94a644450 100644 --- a/system/src/Grav/Console/Cli/CleanCommand.php +++ b/system/src/Grav/Console/Cli/CleanCommand.php @@ -73,6 +73,7 @@ class CleanCommand extends Command 'vendor/gregwar/image/Gregwar/Image/phpunit.xml', 'vendor/gregwar/image/Gregwar/Image/.gitignore', 'vendor/gregwar/image/Gregwar/Image/.git', + 'vendor/gregwar/image/Gregwar/Image/doc', 'vendor/gregwar/image/Gregwar/Image/demo', 'vendor/gregwar/image/Gregwar/Image/tests', 'vendor/gregwar/cache/Gregwar/Cache/composer.json', @@ -90,6 +91,10 @@ class CleanCommand extends Command 'vendor/maximebf/debugbar/composer.json', 'vendor/maximebf/debugbar/.bowerrc', 'vendor/maximebf/debugbar/src/Debugbar/Resources/vendor', + 'vendor/maximebf/debugbar/demo', + 'vendor/maximebf/debugbar/docs', + 'vendor/maximebf/debugbar/tests', + 'vendor/maximebf/debugbar/phpunit.xml.dist', 'vendor/monolog/monolog/composer.json', 'vendor/monolog/monolog/doc', 'vendor/monolog/monolog/phpunit.xml.dist', diff --git a/system/tests/Grav/Test/.gitkeep b/system/tests/Grav/Test/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/system/tests/Grav/TestCase.php b/system/tests/Grav/TestCase.php new file mode 100644 index 000000000..718bbadcb --- /dev/null +++ b/system/tests/Grav/TestCase.php @@ -0,0 +1,8 @@ + + + + + ./Grav/ + + +