src/Lud/Press/PressService.php
<?php namespace Lud\Press;
// @todo split object responsibilities
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Facades\View;
use Lud\Utils\ChainableGroup;
use Symfony\Component\Finder\Finder;
class PressService
{
const FILE_PATH_TYPE = 1;
const URL_PATH_TYPE = 2;
protected $conf = [];
protected $app;
protected $editing = false;
protected $currentEditingCacheInfo;
protected $mustCacheCurrentRequest = true;
public function __construct($app, $conf = [])
{
$this->configure($conf);
$this->app = $app;
}
// findFile accepts fileID or filename. We check if the filename ends with
// a known extension. If so, we remove it.
public function findFile($filename)
{
$fileID = $this->filenameToId($filename);
return $this->index()->getFile($fileID);
}
public function configure($values)
{
$sysValues = array_except($values, ['url','filename']);
$this->conf = array_merge($this->conf, $sysValues);
return array_merge($this->conf, $values);
}
public function getConf($key = null, $default = null)
{
if (null === $key) {
return $this->conf;
} elseif (isset($this->conf[$key])) return $this->conf[$key];
else {
return $default;
}
}
public static function filenameJoin($parts)
{
return implode(DIRECTORY_SEPARATOR, $parts);
}
public function filenameToId($fn)
{
$fn = pathinfo($fn, PATHINFO_BASENAME);
$fnLen = strlen($fn);
foreach ($this->getExtensions() as $ext) {
$dotted = ".$ext";
$extLen = strlen($dotted);
if ($dotted === substr($fn, -$extLen)) {
return substr($fn, 0, $fnLen - $extLen);
}
}
return $fn;
}
public function getExtensions()
{
return array_map(function($ext) {
return ltrim($ext, '.');
}, $this->getConf('extensions'));
}
public function pathInfo($fn, $schema, $type = self::FILE_PATH_TYPE)
{
// pre($fn,"path to match");
if (self::FILE_PATH_TYPE === $type) {
// Work only on the basename
$fn = pathinfo($fn, PATHINFO_BASENAME);
$extensions = $this->getExtensions();
$extensionsRe = '\\.(' . implode('|', $extensions) . ')';
} elseif (self::URL_PATH_TYPE === $type) {
// this is an URL, evaluate the full path
$extensionsRe = '';
}
$pattern = static::filenameSchemaToRegex($schema, $extensionsRe);
$matches = [];
if (preg_match($pattern, $fn, $matches)) {
// we filter out the numeric keys of matches
$keys = array_keys($matches);
$numeric_keys = array_filter($keys, 'is_numeric');
return array_except($matches, $numeric_keys);
// } else {
// dump_r("$pattern NO match $fn");
}
return false;
}
// URLs -----------------------------------------------------------------
public function filenameToUrl($meta)
{
$schemas = $this->getConf('url_map');
foreach ($schemas as $filenameSchema => $urlSchema) {
if ($props = $this->pathInfo($meta->filename, $filenameSchema, self::FILE_PATH_TYPE)) {
return URL::to(static::replaceStrParts($urlSchema, array_merge($props, $meta->all())));
}
}
throw new \Exception('Cannot transform filename "'.$meta->filename.'"');
}
// the schemas must return an ID, i.e. a file's name without the directory
// and without an extension
public function urlToID($urlPath)
{
$schemas = $this->getConf('url_map');
foreach ($schemas as $filenameSchema => $urlSchema) {
if ($props = $this->pathInfo($urlPath, $urlSchema, self::URL_PATH_TYPE)) {
return static::replaceStrParts(static::expandFileNameSchema($filenameSchema), $props);
}
}
throw new UnknownURLSchemaException("Cannot transform URL '$urlPath'");
}
// URLs -----------------------------------------------------------------
/**
* Replaces :symbols in URLs with actual values
* @param string $schema the url schema with :symbols
* @param Closure|array $values values provider. The closure must return null if the key is not defined
* @return string an URL with values set
*/
public static function replaceStrParts($schema, $values)
{
if (is_callable($values)) {
$getVal = $values;
} else {
$getVal = function($key) use ($values) {
return isset($values[$key]) ? $values[$key] : null;
};
}
$keysFound = [];
$matches = [];
if (preg_match_all('/:[a-zA-Z0-9_]+/', $schema, $matches)) {
foreach ($matches[0] as $matchKey) {
$key = substr($matchKey, 1); // drop the colon
$val = $getVal($key);
if ($val !== null) {
$schema = str_replace($matchKey, $val, $schema);
}
}
}
return $schema;
}
public static function filenameSchemaToRegex($schema, $append)
{
switch($schema) {
case 'classic':
$pattern = "@(?P<year>[0-9]{4})-(?P<month>[0-9]{2})-(?P<day>[0-9]{2})-(?P<slug>.+)$append$@";
break;
case 'simple':
$pattern = "@(?P<slug>.+)$append$@";
break;
default: // Custom schema
$schemaToRegex = [
':year' => '(?P<year>[0-9]{4})',
':month' => '(?P<month>[0-9]{2})',
':day' => '(?P<day>[0-9]{2})',
':slug' => '(?P<slug>.+)',
];
$scParts = array_keys($schemaToRegex);
$reParts = array_values($schemaToRegex);
$pattern = '@' . str_replace($scParts, $reParts, $schema) . "$append$@";
}
return $pattern;
}
public static function expandFileNameSchema($schema)
{
switch ($schema) {
case 'classic':
return ':year-:month-:day-:slug';
case 'simple':
return ':slug';
default:
return $schema;
}
}
public function index()
{
return $this->app['press.index'];
}
public function query($query, array $params = array())
{
return $this->index()->query($query, $params);
}
public function all()
{
return $this->index()->all();
}
// Cache & Editing ------------------------------------------------------
public function cache()
{
return $this->app->make('press.cache');
}
public function skipCache()
{
$this->mustCacheCurrentRequest = false;
}
public function isCacheableRequest($request, $response)
{
$routeOpts = $request->route()->getAction();
return
$this->mustCacheCurrentRequest
&& isset($routeOpts['pressCache'])
&& $routeOpts['pressCache']
&& 200 === $response->getStatusCode();
}
public function isEditing()
{
return $this->editing;
}
public function setEditing($editing = true)
{
$this->editing = $editing;
if ($this->editing) {
$this->skipCache();
}
}
public function editingCacheInfo()
{
if (!$this->isEditing()) {
return null;
}
$info = $this->cache()->cacheInfo($this->app->request);
$info->indexMaxMTime = $this->index()->getModTime();
$info->isCacheStale = $info->indexMaxMTime > $info->cacheTime;
return $info;
}
// Themes management ----------------------------------------------------
public function loadThemes()
{
$this->loadTheme($this->themefilePath());
foreach ($this->getConf('load_themes') as $dir) {
$this->loadTheme($dir);
}
}
public function loadTheme($dir)
{
$finder = new Finder;
$finder->files()
->depth('== 0')
->name('_theme.*.php')
->in($dir)
;
if ($finder->count() !== 1) {
throw new ThemeNotFoundException("Press theme could not be loaded from $dir");
}
$themeFileName = array_values(iterator_to_array($finder))[0]->getRelativePathName();
preg_match('/_theme\.(.*)\.php/', $themeFileName, $matches);
$name = $matches[1];
\View::addNamespace($name, $dir);
$this->registeredThemes[$name] = ['dir' => $dir];
}
public function getThemeDir($name)
{
$this->ensureThemeExists($name);
return $this->registeredThemes[$name]['dir'];
}
public function ensureThemeExists($name)
{
if (!isset($this->registeredThemes[$name])) {
throw new \Exception(
"Press theme '$name' does not exist."
);
}
}
public function getDefaultThemeAssets()
{
return $this->getThemeAssets($this->getConf('theme', 'press'));
}
public function getThemeAssets($theme)
{
$cacheKey = "press::themefile->$theme";
if (! isset($this->registeredThemes[$theme])) {
throw new \Exception("Unknown theme $theme");
}
return $this->readTheme($theme);
}
public static function themefilePath()
{
return realpath(__DIR__ . '/../../views');
}
private function readTheme($name)
{
if (isset($this->registeredThemes[$name]['info'])) {
return $this->registeredThemes[$name]['info'];
}
$dir = $this->registeredThemes[$name]['dir'];
$infos = require "$dir/_theme.$name.php";
$empty = [
'styles'=>[],
'scripts'=>[],
'publishes'=>[],
];
$infos = array_merge($empty, $infos);
$this->registeredThemes[$name]['info'] = $infos;
return $infos;
}
public function themesPublishes()
{
$publishes = [];
foreach ($this->registeredThemes as $theme => $_) {
$publishes[] = $this->readTheme($theme)['publishes'];
}
return call_user_func_array('array_merge', $publishes);
}
public function hasHttpExceptionView($statusCode)
{
return View::exists($this->httpExceptionViewName($statusCode));
}
public function renderHttpException($statusCode)
{
$viewData = [
'themeAssets' => $this->getDefaultThemeAssets()
];
$this->setEditing(false);
return response()
->view($this->httpExceptionViewName($statusCode), $viewData, $statusCode);
}
protected function httpExceptionViewName($statusCode)
{
return $this->namespaceView("errors.$statusCode");
}
protected function namespaceView($name)
{
$delim = \Illuminate\View\ViewFinderInterface::HINT_PATH_DELIMITER;
return $this->getConf('theme') . $delim . $name;
}
// Routing --------------------------------------------------------------
/**
* Maps a route to a query of articles
* @param string $path A route path like in Route::get(...)
* @param string $query A press query
* @param array $_options An array of options
* @return \Illuminate\Routing\Route A laravel route set
* @todo split method in smaller parts
* @todo allow post|delete|etc ?
*/
public function listRoute($path, $query, array $_options = array())
{
$as = 'press.'.crc32($query); // fast hash but more collision risks
$options = array_merge(
$this->listRouteOptsWithDefaults(),
compact('query', 'as'),
$_options
);
// now that the options are set, the user could have overriden the route
// 'as' (the route name)
$as = $options['as'];
$options['base_route'] = $as; // we share the base route name with the paginated routes
$routes = [Route::get($path, array_merge($options, ['as' => $as]))]; // base route
// we cannot have optional non-parameters parts in the url, so we must
// define other routes
if ($options['paginate']) {
unset($options['as']);
$p = PressPaginator::PAGE_NAME;
// redirect page 1 to base path
$routes[] = Route::get("$path/$p/1", function(\Illuminate\Routing\Route $route) use ($as) {
$url = URL::route($as, $route->parameters(), $abs = false);
return redirect($url, 301);
});
// other pages
$routes[] = Route::get("$path/$p/{page}", $options)->where('page', '[0-9]+');
}
return new ChainableGroup($routes);
}
private function listRouteOptsWithDefaults()
{
static $base = [
'paginate' => true,
'pressCache' => true,
'uses' => 'Lud\Press\PressPubController@showCollection',
];
return $base;
}
public function setRoutes($_SET_HOME_ROUTE = true)
{
require realpath(__DIR__ . '/../../routes.php');
}
}