<?php
class Phad {
// contains submit(), delete() & utility functions for these
use Phad\Submission;
// idk, does routing stuff?
use Phad\Routes;
// contains can_read_data(), can_read_row(), and can_delete()
use Phad\Can_Do;
// contains extra utility functions
use Phad\Utility;
use Phad\SpamControl;
public ?\PDO $pdo = null;
public $route_prefix = '';
/**
* array of configs, typically from an on-disk json file. These are not directly used by Phad, but may be useful to subclasses or integrations.
*/
public array $configs = [];
/**
* sitemap builder instance
*/
public \Phad\SitemapBuilder $sitemap;
/**
* a router instance
*/
public \Phad\RouterInterface $router;
/**
* Handles all access checks, such as user-role checking and per-row checking
*/
public ?\Phad\AccessInterface $access;
/**
* array of callables that return an array of rows.
* Each callable should accept args `(DomNode, ItemInfo)` & return an array of rows.
*/
public $data_loaders = [];
/**
* set false to stop phad from `exit`ing when calling `->redirect()`
* You can custom handle the `header()` call by creating a `header()` function in the `Phad` namespace.
*
* See https://akrabat.com/replacing-a-built-in-php-function-when-testing-a-component/ to understand the `Phad\header()` thing
*/
public bool $exit_on_redirect = true;
/**
* true to always re-compile views
*/
public bool $force_compile = false;
/**
* args to pass to every phad view
*/
public $global_phad_args = [];
/**
* `key=>value` array of filters where `key` is the filter you write in the html & `value` is a callable
* @feature(ValueFilter) Create a filter by setting `$phad->filters['filter_name'] = callable;`, then declare `<section prop="body" filter="filter_name"`></section>` to use it
*/
public $filters = [
// 'commonmark:markdownToHtml'=>'markdownFilter',
];
/** Absolute path to a directory that contains phad items */
public $item_dir;
/** Absolute path to a directory to store cached files */
public $cache_dir;
/** Dir to write sitemap.xml file to */
public $sitemap_dir;
/** true to throw exception when query failes. false to silently fail & return false */
public bool $throw_on_query_failure = false;
/**
* array of handlers for sitemap building
*/
public $sitemap_handlers = [];
/** array of validation functions
* @key should correspond to a `validate="key"` attribute on an html node.
* @value should be a function with the signature `function($property_value, $property_settings, &$errors): bool`
*/
public $validators = [];
public function __construct(){
if (class_exists('\League\CommonMark\CommonMarkConverter',true)){
$this->filters['commonmark:markdownToHtml']=[$this,'filter_markdown'];
}
}
/**
* Process a data node, perform a query if required, check `AccessInterface::can_read_row()` for each, and return an array of allowed rows. If rows were explicitly passed into the item, then these will be returned instead of any data nodes being processed or queries being performed. Nodes are processed and queries are performed by `class Phad\DataReader`
*
* @override to customize how data is read or to remove the per-row access check. You may use `Phad\DataReader` as a base class for your implementation.
*
* @param $data_node_info array the data node info
* @param $ItemInfo object
* @return an array of rows
*/
public function read_data(array $data_node_info, object $ItemInfo): array {
$reader = new \Phad\DataReader($data_node_info, $ItemInfo, $this->pdo, $this);
$rows = $reader->get_rows();
$final_rows = [];
$access = $this->access;
foreach ($rows as $index=>$row){
if ($access->can_read_row($row, $ItemInfo, $ItemInfo->name))$final_rows[] = $row;
}
return $final_rows;
}
/**
* Modify a query before getting rows
*
* @param $query_info an array with keys sql, limit, orderby, where, etc... if 'sql' is set, the others are ignored. See \Phad\Query->buildSql() for more infromation.
*
* @override to provide custom query modifications
*
* @return array of query info
*/
public function modify_query_info(array $query_info){
throw new \Exception("modify_query_info() is obsolete. Do not call it.");
return $query_info;
}
/**
* Converts an array row (loaded via a data node) into an object.
*
* @override to provide a custom objects
*/
public function object_from_row(array $row, $ItemInfo){
if (isset($row['_object'])) return $row['_object'];
return (object)$row;
}
/**
* When a form is submitted, convert submitted POST data into an object, so that it can be displayed in the form.
*
* Set `$this->enable_filtering = true` to re-enable filtering on input nodes. (*Within your template, `$this` refers to the Item class, not to Phad*)
*
* @override to provide custom objects when processiong `$_POST` data during submission
*/
public function object_from_submission(array $row, $ItemInfo){
if (isset($row['_object'])) return $row['_object'];
return (object)$row;
}
public function has_item($name){
return file_exists($this->item_dir.'/'.$name.'.php');
}
public function item($name, $args=[]){
$args['phad'] = $args['phad'] ?? $this;
$args['is_route'] = $args['is_route'] ?? false;
foreach ($this->global_phad_args as $k=>$v){
if (!isset($args[$k]))$args[$k] = $v;
}
$item = new \Phad\Item($name, $this->item_dir, $args);
$item->force_compile = $this->force_compile;
return $item;
}
/**
* get an item instance from a file
*
* This does not set up any routing
*/
public function item_from_file(string $file_path, array $args=[]){
$args['phad'] = $args['phad'] ?? $this;
foreach ($this->global_phad_args as $k=>$v){
if (!isset($args[$k]))$args[$k] = $v;
}
// remove .php from the file path, for the item 'name'
$item = new \Phad\Item(substr(basename($file_path),0,-4), dirname($file_path), $args);
$item->templateFile = $file_path;
return $item;
}
/**
*
* @param $filterName the name of the filter to pass `$value` through
* @param $value the value you wish to modify
*
* @throws if `$filterName` is not set
* @throws if `$filterName` points to a non-callable
*/
public function filter(string $filterName, $value){
//conditional namespacing would be nice, so the ns: prefix can be left off when there are no conflicts
if (!isset($this->filters[$filterName])){
throw new \Exception("Filter '{$filterName}' is not set.");
}
$filter = $this->filters[$filterName];
if (!is_callable($filter)){
throw new \Exception("Filter '{$filterName}' is not callable.");
}
$filtered = $filter($value);
return $filtered;
}
/**
* Apply commonmark conversion to the value, turning markdown into html
* @param $markdown the value which is markdown and should become html
*/
public function filter_markdown($markdown){
$converter = new \League\CommonMark\CommonMarkConverter([
'html_input' => 'strip',
'allow_unsafe_links' => false,
]);
if (method_exists($converter,'convert')){
$html = $converter->convert($markdown);
} else {
$html = $converter->convertToHtml($markdown);
}
return $html;
}
/**
* Return rows when `$item->rows()` is called. The rows have already been loaded into `$ItemInfo->rows`, and `can_read_row()` has already been checked for each one.
*
* @override to provide filtering or modification of data before rows are returned as an array.
*
* @param $ItemInfo object
* @return array of rows
*/
public function get_rows($ItemInfo): array {
return $ItemInfo->rows;
}
public function setup_routes(){
$routes = $this->routes_from_cache($this->force_compile);
foreach ($routes as $pattern=>$item_name){
///////
// TODO: Select GET vs POST based on whether the item is a view or a form
// TODO: maybe route to a file instead of a route handler?
///////
$this->router->add_route($pattern, ['GET', 'POST']);
}
// TODO: Sitemap route should be optional
$this->router->add_file_route('/sitemap.xml', $this->sitemap_dir.'/sitemap.xml', ['GET']);
}
/**
* Create a PHAD instance using the default integrations with `taeluf/liaison` and `taeluf/user-gui`. Configs are optional. Use `vendor/bin/phad setup` to generate a config file.
*
*/
static public function main(\PDO $pdo, string $root_dir, \Lia $liaison, ?\Tlf\User\Package $user_package=null, array $configs = []): static {
return
static::custom($pdo, $root_dir,
new \Phad\Integration\LiaisonRouter($liaison),
$user_package==null ? null : new \Phad\Integration\TlfUserAccess($user_package),
$configs
);
}
static public function custom(\PDO $pdo, string $root_dir, \Phad\RouterInterface $router, \Phad\AccessInterface $access_controller = null, array $configs = []): static {
$class = static::class;
$phad = new $class();
$configs =
array_merge(
json_decode(file_get_contents(__DIR__.'/defaults.json'), true, 512, JSON_THROW_ON_ERROR)
,$configs
);
$phad->item_dir = $root_dir.'/'.$configs['dir.templates'];
$phad->cache_dir = $root_dir.'/'.$configs['dir.cache'];
$phad->sitemap_dir = $root_dir.'/'.$configs['dir.sitemap'];
$phad->pdo = $pdo;
$phad->router = $router;
$phad->access = $access_controller;
$phad->throw_on_query_failure = $configs['query.throw_on_failure'];
$phad->force_compile = $configs['compile.force'];
$sitemap_builder = new \Phad\SitemapBuilder($phad->sitemap_dir);
$sitemap_builder->cache_dir = $phad->cache_dir;
$sitemap_builder->pdo = $phad->pdo;
$sitemap_builder->throw_on_query_failure = $phad->throw_on_query_failure;
$sitemap_builder->router = $router;
$sitemap_builder->handlers = &$phad->sitemap_handlers;
$phad->sitemap = $sitemap_builder;
// TODO: Setup user integration
$router->set_phad_object($phad);
return $phad;
}
/**
* Make a sitemap file from all views
* @todo add caching of the sitemap file
* @return path to the new sitemap file
*/
public function create_sitemap_file(): string{
if (file_exists($this->sitemap_dir.'/sitemap.xml')){
unlink($this->sitemap_dir.'/sitemap.xml');
}
$items = $this->get_all_items();
$sm_builder = $this->sitemap;
$sm_list = $sm_builder->get_sitemap_list($items, $this);
$sm_builder->make_sitemap($sm_list);
return $this->sitemap_dir.'/sitemap.xml';
}
public function compile_all_items(){
$items = $this->get_all_items();
foreach ($items as $i){
$item = $this->item($i,[]);
$item->compile();
}
}
/**
* parse a string like `print:woohoo;call:somethin;role:admin;`
* @return array with key=function and value=args like `key=print` & `value=woohoo`
*/
public function parse_functions(?string $call_string){
if ($call_string===null)return [];
$functions = explode(';', $call_string);
if (count($functions)==1&&trim($functions[0])=='')return [];
// print_r($functions);
// var_dump($call_string);
// exit;
foreach ($functions as $f){
$f = trim($f);
if ($f=='')continue;
$parts = explode(':', $f, 2);
$method = $parts[0];
$c = count($parts);
if ($c==2){
$arg = $parts[1];
$out[$method] = $arg;
} else if ($c==1&&$parts[0]!=''){
$out['std'] = $parts[0];
} else {
throw new \Exception(sprintf(\Phad\Errors::PARSE_FUNCTIONS_TOO_MANY, $f));
}
}
return $out;
}
/**
* Handle when no rows were loaded. This method is for overriding and does literally nothing otherwise.
*
* @param $ItemInfo an item info object
* @output is optional and likely would contain some kind of error message.
* @override to handle when no rows have been loaded
*/
public function no_rows_loaded(\Phad\ItemInfo $ItemInfo){
}
/**
* Handle when a node cannot be read
*
* @param $node the node info
* @output is optional and may contain some kind of error output
*/
public function read_node_failed(array $node){
}
/**
* replace each `$value` with `htmlspecialchars($value)`
* @param &$row a row of data, ideally prior to submission
*/
public function sanitize_user_row(array &$row){
foreach (array_keys($row) as $key){
if (!is_numeric($key));
$row[$key] = htmlspecialchars($row[$key]);
}
}
/**
* Fill a url pattern like `/blog/{slug}/` from an array of values like `['slug'=>'some-post']`
*
* @return a url like `/blog/some-post/`
*/
static public function fill_pattern(string $pattern, array $values): string {
foreach ($values as $key=>$value){
$pattern = str_replace('{'.$key.'}', $value, $pattern);
}
return $pattern;
}
/**
* Parse a pattern like `/blog/{slug}/` and return params in the pattern like `['slug']`
*/
static public function get_url_params(string $url_pattern): array {
preg_match_all('/(?!\/)\{([a-zA-Z\-\_\.]+)\}(?=(\/|$))/', $url_pattern, $matches);
return $matches[1];
}
}