EN | NL | 中文

Core

object

%cookies

/phlo/resources/cookies.phlo
version 1.0
creator q-ai.nl
summary Cookies data object
package web
frontend false
backend true
tags cookies session browser web
method

%cookies -> controller

line 9
this->objData = $_COOKIE
prop

%cookies -> lifetimeDays

line 11
180
method

%cookies -> objSet ($key, $value, array $options = [])

line 13
$this->objData[$key] = $value
$_COOKIE[$key] = $value
$defaults = ['expires' => time() + $this->lifetimeDays * 86400, 'path' => slash, 'secure' => %req->secure, 'httponly' => true, 'samesite' => 'Lax']
setcookie($key, $value, array_merge($defaults, $options))
return true
method

%cookies -> __unset ($key)

line 21
unset($this->objData[$key], $_COOKIE[$key])
$options = ['expires' => time() - 86400, 'path' => slash, 'secure' => %req->secure, 'httponly' => true, 'samesite' => 'Lax']
setcookie($key, void, $options)
object

%lang

/phlo/resources/lang.phlo
version 1.1
creator q-ai.nl
summary Language and translation resource
package i18n
frontend false
backend true
requires @cookies @OpenAI @INI phlo.async
advice Use %lang in views to show current app lang (for example in links)
tags lang translation i18n locale ai
function

function nl ($text, ...$args)

line 11
%lang->translation('nl', $text, ...$args)
function

function en ($text, ...$args)

line 12
%lang->translation('en', $text, ...$args)
static

lang :: asyncBatch ($from, $to, $json)

line 14
%app->lang = $to
$texts = json_decode($json, true)
$translations = $this->translateBatch($from, $to, $texts)
if ($translations) $this->save($to, $translations)
view

%lang -> view

line 21
%app->lang
prop

%lang -> model

line 23
'gpt-4o-mini'
static

lang :: fileCache

line 24
[]
method

%lang -> file ($lang)

line 26
langs.$lang.'.ini'
method

%lang -> escape ($value)

line 28
strtr((string)$value, [bs => bs.bs, dq => bs.dq, lf => '\n'])
method

%lang -> unescape ($value)

line 29
strtr(strtr($value, [bs.bs => "\x01", bs.dq => dq, '\n' => lf]), ["\x01" => bs])
method

%lang -> lineValue ($line, $eq)

line 31
$value = rtrim(substr($line, $eq + 3), cr.lf)
if (strlen($value) > 1 && $value[0] === dq && substr($value, -1) === dq) $value = substr($value, 1, -1)
return $this->unescape($value)
method

%lang -> readAll ($file)

line 37
$items = []
if (!is_file($file)) return $items
foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] AS $line){
	$eq = strpos($line, ' = ')
	if ($eq === false) continue
	$items[substr($line, 0, $eq)] = $this->lineValue($line, $eq)
}
return $items
method

%lang -> search ($file, $hash)

line 48
$size = (int)@filesize($file)
if (!$size) return null
$h = @fopen($file, 'rb')
if (!$h) return null
$lo = 0
$hi = $size
while ($hi - $lo > 4096){
	$mid = intdiv($lo + $hi, 2)
	fseek($h, $mid)
	fgets($h)
	$pos = ftell($h)
	if ($pos >= $hi){
		$hi = $mid
		continue
	}
	$line = (string)fgets($h)
	$eq = strpos($line, ' = ')
	if ($eq === false){
		$hi = $mid
		continue
	}
	$cmp = strcmp(substr($line, 0, $eq), $hash)
	if ($cmp < 0) $lo = ftell($h)
	elseif ($cmp > 0) $hi = $pos
	else {
		fclose($h)
		return $this->lineValue($line, $eq)
	}
}
fseek($h, $lo)
$value = null
while (ftell($h) < $hi && ($line = fgets($h)) !== false){
	$eq = strpos($line, ' = ')
	if ($eq === false) continue
	$cmp = strcmp(substr($line, 0, $eq), $hash)
	if ($cmp > 0) break
	if ($cmp === 0){
		$value = $this->lineValue($line, $eq)
		break
	}
}
fclose($h)
return $value
method

%lang -> lookup ($hash)

line 94
$file = $this->file(%app->lang)
$mtime = (int)@filemtime($file)
$cache =& static::$fileCache[$file]
if (!$cache || $cache['mtime'] !== $mtime) $cache = ['mtime' => $mtime, 'items' => []]
if (array_key_exists($hash, $cache['items'])) return $cache['items'][$hash]
$value = $this->search($file, $hash)
if ($value === null && $mtime) $value = $this->readAll($file)[$hash] ?? null
return $cache['items'][$hash] = $value
method

%lang -> save ($lang, $pairs)

line 105
$file = $this->file($lang)
$items = $this->readAll($file)
foreach ($pairs AS $hash => $value) $items[$hash] = $value
ksort($items, SORT_STRING)
$out = void
foreach ($items AS $hash => $value) $out .= $hash.' = '.dq.$this->escape($value).dq.lf
$tmp = $file.'.'.getmypid().'.tmp'
file_put_contents($tmp, $out, LOCK_EX)
@chmod($tmp, 0664)
rename($tmp, $file)
unset(static::$fileCache[$file])
method

%lang -> transContext

line 119
($instr = trim((string)(%app->transInstr ?? void))) !== void ? lf.'Context from the app author about purpose and domain: '.$instr : void
prop

%lang -> browser

line 121
last($langs = array_filter(explode(comma, %req->acceptLanguage), fn($lang) => isset(%app->langs[substr($lang, 0, 2)])), $langs ? substr(current($langs), 0, 2) : null)
method

%lang -> cookie

line 122
($lang = %cookies->lang) && %app->langs[$lang] ? $lang : null
method

%lang -> detect ($text, $fallback = 'en')

line 123
$res = %OpenAI->chat (
	model: $this->model,
	system: 'Analyse which language this text is in and return only the ISO 639-1 code of the language, no other data!',
	user: $text.lf.lf.'The ISO 639-1 code of the language is: ',
	temperature: 0,
)->answer
return strlen($res) === 2 ? strtolower($res) : $fallback
method

%lang -> hash ($from, $text)

line 132
$from.($short = substr(implode(regex_all('/[A-Za-z0-9]+/', ucwords($text))[0]), 0, 8)).substr(md5($text), 0, 10 - strlen($short))
method

%lang -> translation ($from, $text, ...$args)

line 133
if ($from === %app->lang) $translation = strtr($text, ['\n' => lf])
else {
	$translation = []
	$missing = []
	foreach (explode(lf, $text) AS $line){
		if (trim($line)){
			$hash = $this->hash($from, $line)
			$item = $this->lookup($hash)
			if ($item === null) [$missing[$hash] = $item = $line, debug(%app->lang.': '.(strlen($line) > 20 ? substr($line, 0, 18).'...' : $line))]
		}
		else $item = void
		$translation[] = $item
	}
	if ($missing) phlo_async('lang::asyncBatch', $from, %app->lang, json_encode($missing))
	$translation = implode(lf, $translation)
}
return $args ? sprintf($translation, ...$args) : $translation
method

%lang -> translate ($from, $to, $text)

line 152
if ($from === $to) return $text
return %OpenAI->chat (
	model: $this->model,
	system: "You will be provided with a word, sentence or (markdown) text in ISO 639-1 language $from, and your task is to translate this string into ISO 639-1 language $to. Respect markdown, missing interpunction and specific use of capitals. Give only the translation.".$this->transContext(),
	user: $text,
	temperature: 0,
)->answer
method

%lang -> translateBatch ($from, $to, $texts)

line 161
if ($from === $to) return $texts
$hashes = array_keys($texts)
$numbered = implode(lf, array_map(fn($i, $t) => ($i + 1).'. '.$t, array_keys($values = array_values($texts)), $values))
$answer = %OpenAI->chat (
	model: $this->model,
	system: "You will be provided with numbered lines in ISO 639-1 language $from. Translate each line into ISO 639-1 language $to. Return only the numbered translations in the same format. Respect markdown, missing interpunction and specific use of capitals.".$this->transContext(),
	user: $numbered,
	temperature: 0,
)->answer
$result = []
foreach (explode(lf, trim($answer)) AS $line){
	if (preg_match('/^(\d+)\.\s*(.+)/', $line, $m))
		$result[$hashes[(int)$m[1] - 1]] = $m[2]
}
return $result
object

%payload

/phlo/resources/payload.phlo
version 1.0
creator q-ai.nl
summary POST, PUT, PATCH and file-upload data object
package web
frontend false
backend true
requires @file
tags payload request upload post put patch
method

%payload -> controller

line 10
contentType = %req->contentType
if (in_array(phlo('req')->method, ['POST', 'PUT', 'PATCH']) && str_starts_with($contentType, 'application/json')){
$data = json_read('php://input')
return $this->objData = is_object($data) ? get_object_vars($data) : (is_array($data) ? $data : [])
}
if ($_POST) $this->objImport(...$_POST)
elseif (in_array(phlo('req')->method, ['PUT', 'PATCH']) && str_starts_with($contentType, 'application/x-www-form-urlencoded')){
$body = file_get_contents('php://input')
$data = []
parse_str($body, $data)
if ($data) $this->objImport(...$data)
}
elseif (in_array(phlo('req')->method, ['PUT', 'PATCH']) && str_starts_with($contentType, 'multipart/form-data')){
$match = regex('/boundary="?([^";]+)"?/', $contentType)
if (!$match) return
$boundary = '--'.$match[1]
$arrays = []
$raw = file_get_contents('php://input')
foreach (explode($boundary, $raw) AS $part){
	if (!trim($part) || $part === '--' || !str_contains($part, nl.nl)) continue
	$headers = []
	[$rawHeaders, $body] = explode(nl.nl, $part, 2)
	foreach (explode(nl, trim($rawHeaders)) AS $header){
		if (str_contains($header, colon)){
			[$key, $value] = explode(colon, $header, 2)
			$headers[strtolower(trim($key))] = trim($value)
		}
	}
	if (!isset($headers['content-disposition'])) continue
	if (!preg_match('/name="([^"]+)"/', $headers['content-disposition'], $match)) continue
	$name = $match[1]
	$body = rtrim($body, nl)
	if ($body === void) $body = null
	$base = $name
	$keys = []
	$hasEmptyIndex = false
	if (preg_match('/^([^\[]+)((?:\[[^\]]*\])*)$/', $name, $m)){
		$base = $m[1]
		$brackets = $m[2]
		if ($brackets){
			preg_match_all('/\[([^\]]*)\]/', $brackets, $mm)
			$keys = $mm[1]
			$hasEmptyIndex = in_array(void, $keys, true)
		}
	}
	if ($hasEmptyIndex) $arrays[] = $base
	$assign = function($value) use ($base, $keys){
		if ($keys){
			if (!isset($this->objData[$base]) || !is_array($this->objData[$base])) $this->objData[$base] = []
			$ref =& $this->objData[$base]
			$count = count($keys)
			foreach ($keys AS $i => $k){
				$last = $i === $count - 1
				if ($k === void){
					if ($last) $ref[] = $value
					else {
						$ref[] = []
						end($ref)
						$idx = key($ref)
						$ref =& $ref[$idx]
					}
				}
				else {
					if ($last) $ref[$k] = $value
					else {
						if (!isset($ref[$k]) || !is_array($ref[$k])) $ref[$k] = []
						$ref =& $ref[$k]
					}
				}
			}
		}
		else $this->objData[$base] = $value
	};
	if (preg_match('/filename="([^"]*)"/', $headers['content-disposition'], $f)){
		if ($f[1] === void || $body === null){
			if (!$hasEmptyIndex) $assign(null)
			continue
		}
		$filename = $f[1]
		$file = %file(tempnam(sys_get_temp_dir(), 'phlo'), $filename, $body)
		$assign($file)
	}
	else $assign($body)
}
foreach ($this->objData AS $key => $val){
	if (str_ends_with($key, '[]')){
		unset($this->objData[$key])
		$this->objData[substr($key, 0, -2)] = is_array($val) ? array_values(array_filter($val, fn($v) => $v !== null)) : [$val]
	}
	elseif (!is_array($val) && substr($key, -2) === '[]') $this->objData[$key] = [$val]
}
foreach (array_unique($arrays) AS $key) if (!isset($this->objData[$key])) $this->objData[$key] = []
}
if ($_FILES) $this->objImport(...loop($_FILES, fn($f) => is_array($f['name']) ? loop(array_keys($f['name']), fn($i) => $f['error'][$i] ? null : %file($f['tmp_name'][$i], $f['name'][$i], mime: $f['type'][$i], size: $f['size'][$i])) : ($f['error'] ? null : %file($f['tmp_name'], $f['name'], mime: $f['type'], size: $f['size']))))
object

%session

/phlo/resources/session.phlo
version 1.0
creator q-ai.nl
summary Session data object
package web
frontend false
backend true
tags session web state
method

%session -> controller

line 9
ession_start()
$this->objData = $_SESSION
method

%session -> __set ($key, $value)

line 12
$_SESSION[$key] = $this->objData[$key] = $value
method

%session -> __unset ($key)

line 13
unset($this->objData[$key], $_SESSION[$key])
method

%session -> __isset ($key)

line 14
isset($this->objData[$key])
method

%session -> objRegenerateId ($deleteOld = true)

line 16
session_regenerate_id($deleteOld)
$this->objData = $_SESSION
object

%sitemap

/phlo/resources/sitemap.phlo
version 1.0
creator q-ai.nl
summary Generic multilingual sitemap generator
package seo
frontend false
backend true
requires output
tags sitemap seo multilingual xml
route

route GET sitemap.xml

line 10
output($this)
method

%sitemap -> intl ($uri)

line 12
(%app->slugs ?? [])[$uri] ?? $uri
view

%sitemap -> view

line 14
<?xml version=1.0 encoding="UTF-8"?>
<urlset xmlns=http://www.sitemaps.org/schemas/sitemap/0.9 xmlns:xhtml=http://www.w3.org/1999/xhtml xmlns:xsi=http://www.w3.org/2001/XMLSchema-instance xsi:schemaLocation=http://www.sitemaps.org/schemas/sitemap/0.9+http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd>
	<foreach %app->pages AS $uri>
		{{ $this->page($uri) }}
	</foreach>
</urlset>
view

%sitemap -> page ($uri)

line 22
<url>
	<loc>%req->base{( $uri ?: slash )}</loc>
	<foreach array_keys(%app->langs) AS $lang>
		<if $lang === %app->lang>
			{{ $this->xlink('x-default', $uri ?: slash) }}
		</if>
		{{ $this->xlink($lang, $lang === %app->lang ? ($uri ?: slash) : "/$lang".($this->intl($uri) ?: void)) }}
	</foreach>
</url>
view
line 33
<xhtml:link rel=alternate hreflang="$lang" href="%req->base$uri"{{ slash }}>
view
line 34
<link rel=alternate hreflang="$lang" href="%req->base$uri">
object

%tasks

/phlo/resources/tasks.phlo
version 1.1
creator q-ai.nl
summary Cron runner for %app->tasks. One cron entry per app triggers this every minute.
package scheduling
frontend false
backend true
tags cron schedule tasks scheduler
static

tasks :: dir

line 9
data.'tasks/'
static

tasks :: run

line 11
is_dir(static::dir()) || mkdir(static::dir(), 0755, true)
$now = time()
foreach (%app->tasks ?? [] AS $name => $task){
	$task = (object)$task
	if (!static::due($name, $task, $now)) continue
	if (!static::lock($name)) continue
	$schedule = array_intersect_key((array)$task, array_flip(['every', 'daily', 'weekly']))
	$do = is_string($task->do) ? $task->do : null
	static::saveRun($name, $do, $schedule, static::fire($task->do))
	static::markRun($name, $now)
	static::unlock($name)
}
static

tasks :: saveRun ($name, $do, $schedule, $return)

line 26
json_write(static::dir().$name.'.json', arr(do: $do, schedule: $schedule, return: $return))
static

tasks :: due ($name, $task, $now)

line 28
$last = static::lastRun($name)
if (isset($task->every)){
	$every = preg_match('/^\d/', $task->every) ? $task->every : '1 '.$task->every
	$seconds = strtotime("+$every", 0) ?: 0
	return $seconds > 0 && ($now - $last) >= $seconds
}
if (isset($task->daily)){
	if (date('H:i', $now) !== $task->daily) return false
	return $last < strtotime('today 00:00', $now)
}
if (isset($task->weekly)){
	if (date('D H:i', $now) !== date('D H:i', strtotime("$task->weekly today"))) return false
	return $last < strtotime('monday this week', $now)
}
return false
static

tasks :: fire ($do)

line 46
if ($do instanceof \Closure) return $do()
if (is_string($do) && str_contains($do, '::')){
	[$class, $method] = explode('::', $do, 2)
	return $class::$method()
}
if (is_string($do)) return phlo($do)
error('Task do must be Closure, "Class::method" string, or resource-name string')
static

tasks :: lastRun ($name)

line 56
$file = static::dir().$name.'.last'
return is_file($file) ? (int)file_get_contents($file) : 0
static

tasks :: markRun ($name, $ts)

line 61
file_put_contents(static::dir().$name.'.last', (string)$ts, LOCK_EX)
static

tasks :: lock ($name)

line 63
$file = static::dir().$name.'.lock'
if (is_file($file) && (time() - filemtime($file)) < 3600) return false
touch($file)
return true
static

tasks :: unlock ($name)

line 70
@unlink(static::dir().$name.'.lock')
object

%useragent

/phlo/resources/useragent.phlo
version 1.0
creator q-ai.nl
summary User agent information
package web
frontend false
backend true
tags useragent browser os device web
prop

%useragent -> source

line 9
%req->userAgent ?: null
prop

%useragent -> os

line 11
if (!$this->source) return 'Unknown'
$list = [
	'Android' => '/Android/i',
	'iPadOS' => '/iPad.*OS/i',
	'iOS' => '/iPhone|iPod/i',
	'Windows' => '/Windows NT/i',
	'macOS' => '/Mac OS X/i',
	'ChromeOS' => '/CrOS/i',
	'Linux' => '/Linux/i',
]
foreach ($list AS $n => $r) if (preg_match($r, $this->source)) return $n
if (preg_match('/iPad/i',$this->source) && preg_match('/Mac OS X/i',$this->source)) return 'iPadOS'
return 'Unknown'
prop

%useragent -> osV

line 27
if (!$this->source) return void
if (preg_match('/(?:Android|OS X|OS|Windows NT)\s*([0-9._]+)/i', $this->source, $m)){
	$v = strtr($m[1], [us => dot])
	$v = preg_replace('/[^0-9.].*/', void, $v)
	$v = preg_replace('/(?:\.0)+$/', void, $v)
	return $v
}
return void
prop

%useragent -> osFull

line 38
if (!$this->OS) return 'Unknown'
$v = $this->osV
if (!$v) return $this->OS
$short = preg_replace('/^(\d+\.\d+).*/','$1',$v)
if (preg_match('/\.0$/',$short)) $short = preg_replace('/\.0$/', void, $short)
return trim($this->OS.' '.$short)
prop

%useragent -> name

line 47
if (!$this->source) return 'Unknown'
if (preg_match('/\bwv\b/',$this->source) || (preg_match('/Version\/\d+\.\d+/',$this->source) && strpos($this->source,'Chrome/')!==false && strpos($this->source,'Safari/')!==false && strpos($this->source,' Mobile ')!==false)) return 'Android WebView'
if (preg_match('/CriOS\/([0-9.]+)/',$this->source)) return 'Chrome'
if (preg_match('/FxiOS\/([0-9.]+)/',$this->source)) return 'Firefox'
$list = [
	'Edge' => '/Edg\/([0-9.]+)/',
	'Opera' => '/OPR\/([0-9.]+)/',
	'Samsung Internet' => '/SamsungBrowser\/([0-9.]+)/i',
	'Chrome' => '/Chrome\/([0-9.]+)/',
	'Firefox' => '/Firefox\/([0-9.]+)/',
	'Safari' => '/Version\/([0-9.]+).*Safari/i',
]
foreach ($list AS $n => $r) if (preg_match($r, $this->source)) return $n
return 'Unknown'
prop

%useragent -> version

line 64
if (!$this->source) return void
if (preg_match('/(?:Edg|OPR|Chrome|Firefox|Version|CriOS|FxiOS|SamsungBrowser)\/([0-9.]+)/', $this->source, $m)){
	$v = $m[1]
	$v = preg_replace('/[^0-9.].*/', void, $v)
	$v = preg_replace('/(?:\.0)+$/', void, $v)
	return $v
}
return void
prop

%useragent -> full

line 75
if (!$this->name) return 'Unknown'
$v = $this->version
if (!$v) return $this->name
$short = preg_replace('/^(\d+\.\d+).*/','$1',$v)
if (preg_match('/\.0$/',$short)) $short = preg_replace('/\.0$/', void, $short)
return rtrim($this->name.' '.$short)
prop

%useragent -> device

line 84
if (!$this->source) return 'Unknown'
if (preg_match('/iPad|Tablet|Tab|SM-T|Nexus 7|Nexus 10/i', $this->source)) return 'Tablet'
if (preg_match('/Mobile|iPhone|Android.*Mobile|SM-G|Pixel [0-9]/i', $this->source)) return 'Phone'
return 'Desktop'
object

%visitors

/phlo/resources/visitors.phlo
version 1.0
creator q-ai.nl
summary Visitor tracking via heartbeat
extends model
package analytics
frontend false
backend true
requires @payload @model token useragent
tags visitors analytics heartbeat tracking
static

visitors :: table

line 11
'visitors'
static

visitors :: columns

line 12
'id,token,host,page,lang,IP,browser,os,device,requests,state,width,height,referrer,created,changed'
static

visitors :: history

line 14
static::records(columns: 'FROM_UNIXTIME(changed, "%Y-%m-%d") AS date,COUNT(DISTINCT token) AS visitors,COUNT(id) AS visits', group: 'date', order: 'date DESC')
static

visitors :: online

line 15
static::item(columns: 'COUNT(DISTINCT token)', where: 'changed >= (UNIX_TIMESTAMP() - 9)')
static

visitors :: lastHour

line 16
static::item(columns: 'COUNT(DISTINCT token)', where: 'changed >= (UNIX_TIMESTAMP() - 3600)')
static

visitors :: isBot (?string $ua):bool

line 18
if (!$ua) return false
return (bool)preg_match('/bot|crawl|spider|slurp|baiduspider|facebookexternalhit|twitterbot|linkedinbot|curl|wget|python-requests|go-http-client|java\//i', $ua)
static

visitors :: parseReferrer (string $url):string

line 23
static $engines = ['google' => 'Google', 'bing' => 'Bing', 'duckduckgo' => 'DuckDuckGo', 'yahoo' => 'Yahoo', 'baidu' => 'Baidu', 'yandex' => 'Yandex', 'ecosia' => 'Ecosia', 'startpage' => 'Startpage', 'brave' => 'Brave', 'kagi' => 'Kagi']
$host = strtolower(preg_replace('/^www\./', '', (string)(parse_url($url, PHP_URL_HOST) ?? '')))
foreach ($engines AS $key => $name) if (str_contains($host, $key)) return 'search:'.$name
return $host ?: substr($url, 0, 100)
route

route PUT heartbeat @n,v,l,u,w,h,a,p,r,c

line 30
if (static::isBot(%useragent->source)) return
$consent = (bool)%payload->c
$n = strlen(%payload->n) === 8 ? %payload->n : date('Ymd')
$id = $consent ? token(20, $n.space.%app->token.space.%useragent->source) : token(20, $n.space.date('Ymd').space.%app->token.space.%req->ip)
$data = arr (
	token: token(20, (string)(%app->token ?? error('No app token available'))),
	host: host,
	page: %payload->u,
	lang: strlen(%payload->l) === 2 ? %payload->l : %app->lang ?? 'en',
	IP: $consent ? %req->ip : void,
	browser: $consent ? %useragent->full.(%payload->a ? ' App' : void) : substr(md5((string)%useragent->source), 0, 8),
	os: $consent ? %useragent->osFull : void,
	device: $consent ? %useragent->device : void,
	requests: %payload->p,
	state: %payload->v,
	width: %payload->w,
	height: %payload->h,
	changed: time(),
)
$record = static::record(id: $id, columns: 'id,page,referrer')
if (($referrer = %payload->r) && !$record?->referrer && !str_contains($referrer, host)) $data['referrer'] = static::parseReferrer($referrer)
if ($record) static::change('id=?', $id, ...$data)
else static::create(...$data, id: $id, created: time())
view

script

line 56
let curpath = app.path
let heartbeatTimeout
const heartbeat = () => delay('heartbeat', 333, () => {
	clearTimeout(heartbeatTimeout)
	const consent = document.cookie.includes('cookieChoice=all')
	if (consent){
		const m = document.cookie.match(/phlo_visitor=([a-z0-9]{8})/)
		if (m) window.name = m[1]
		else {
			window.name ||= phlo.token(8)
			document.cookie = `phlo_visitor=${window.name};path=/;max-age=${365 * 86400};SameSite=Lax`
		}
	}
	else window.name ||= phlo.token(8)
	fetch('/heartbeat', {method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({n: window.name, v: app.state, l: obj('html').lang ?? 'en', u: app.path, w: innerWidth, h: innerHeight, a: app.mode, p: phlo.state.index, r: (r = document.referrer) ? (r === `${location.origin}/` ? null : r) : null, c: consent ? 1 : 0})})
	heartbeatTimeout = setTimeout(heartbeat, 20000)
})
document.addEventListener('visibilitychange', heartbeat)
;['focus', 'blur', 'resize'].forEach(e => addEventListener(e, heartbeat))
app.updates.push(() => curpath !== app.path && (heartbeat(), curpath = app.path))
heartbeat()
object

%websocket

/phlo/resources/websocket.phlo
version 1.0
creator q-ai.nl
summary Server-side WebSocket handler via PhloWS
advice Enable this class only when websockets are configured for the host
package realtime
frontend false
backend true
tags websocket realtime ws server
static

websocket :: connect ($host, $token, $socket)

line 10
function_exists('wsConnect') && wsConnect($host, $token, $socket)
static

websocket :: auth ($host, $token, $socket)

line 11
function_exists('wsAuth') && wsAuth($host, $token, $socket)
static

websocket :: receive ($host, $token, $socket, $data)

line 12
function_exists('wsReceive') && wsReceive($host, $token, $socket, ...json_decode($data, true))
static

websocket :: close ($host, $token, $socket)

line 13
function_exists('wsClose') && wsClose($host, $token, $socket)
object

%WhatsApp

/phlo/resources/WhatsApp.phlo
version 1.0
creator q-ai.nl
summary WhatsApp client for PhloWA using whatsapp-web.js
package messaging
frontend false
backend true
requires HTTP
tags whatsapp messaging api
method

%WhatsApp -> __construct (public string $url, public string $secret)

line 10
$this->url = rtrim($url, slash).slash
static

WhatsApp :: channel ($channel)

line 12
new static($channel->configData->url ?? 'http://localhost:8081', $channel->secretData->secret ?? void)
method

%WhatsApp -> number ($contact)

line 14
($pos = strpos($contact, '@')) ? substr($contact, 0, $pos) : error('Invalid contact: '.esc($contact))
method

%WhatsApp -> isGroup ($contact)

line 15
last($this->number($contact), (bool)strpos($contact, '@g'))
method

%WhatsApp -> status

line 17
$this->request('status', GET: true)
method

%WhatsApp -> health

line 18
$this->request('health', GET: true)
method

%WhatsApp -> qr

line 19
$this->request('qr', GET: true)
method

%WhatsApp -> disconnect

line 20
$this->request('disconnect')
method

%WhatsApp -> read ($chat)

line 22
$this->request('read', chat: $chat)
method

%WhatsApp -> reaction ($msg, $emoji)

line 23
$this->request('reaction', msg: $msg, emoji: $emoji)
method

%WhatsApp -> text ($to, $text)

line 25
$this->request('text', to: $to, text: $text)
method

%WhatsApp -> image ($to, file $file, $text = void)

line 26
$this->request('image', to: $to, filename: $file->name, image: $file->src, text: $text)
method

%WhatsApp -> location ($to, $lat, $lon, $text)

line 27
$this->request('location', to: $to, lat: $lat, lon: $lon, text: $text)
method

%WhatsApp -> document ($to, file $file, $text = void)

line 28
$this->request('document', to: $to, filename: $file->name, document: $file->src, text: $text)
method

%WhatsApp -> audio ($to, file $file)

line 30
$this->request('audio', to: $to, audio: $file->src)
method

%WhatsApp -> voice ($to, file $file)

line 31
$this->request('voice', to: $to, audio: $file->src)
method

%WhatsApp -> poll ($to, $name, array $options, bool $multi = false)

line 33
$this->request('poll', to: $to, name: $name, options: $options, multi: $multi)
method

%WhatsApp -> startTyping ($to)

line 35
$this->request('typing/start', to: $to)
method

%WhatsApp -> stopTyping ($to)

line 36
$this->request('typing/stop', to: $to)
method

%WhatsApp -> request ($action, ...$data)

line 38
$get = $data['GET'] ?? false
unset($data['GET'])
$raw = trim((string)HTTP($this->url.$action, ['secret: '.$this->secret], true, $get ? null : $data))
if (strtolower($raw) === 'ok') return obj(ok: true)
$res = json_decode($raw)
if (!$res && $raw) return obj(ok: false, error: $raw)
return $res ?: obj(ok: false, error: 'Empty WhatsApp response')

Functions

function

active(bool $cond, string $classList = void)

/phlo/resources/active.phlo line 7
$cond || $classList ? ' class="'.$classList.($cond ? ($classList ? space : void).'active' : void).'"' : void
function

age(int $time)

/phlo/resources/age.phlo line 7
time() - $time
function

age_human(int $age)

/phlo/resources/age.human.phlo line 8
time_human(time() - $age)
function

apcu($key, $cb, int $duration = 3600, bool $log = true)

/phlo/resources/apcu.phlo line 8
first($value = apcu_entry($key, $cb, $duration), $log && debug('C: '.(strlen($key) > 58 ? substr($key, 0, 55).'...' : $key).(is_array($value) ? ' ('.count($value).')' : (is_numeric($value) ? ":$value" : (is_string($value) ? ':string:'.strlen($value) : colon.gettype($value))))))
function

await(...$jobs)

/phlo/resources/await.phlo line 10
	$children = []
	foreach ($jobs AS $i => $job){
		[$cb, $args] = is_array($job) ? [$job[0], array_slice($job, 1)] : [$job, []]
		$cmd = cli.space.escapeshellarg((string)$_SERVER['SCRIPT_FILENAME']).space.escapeshellarg($cb).loop($args, fn($a) => space.escapeshellarg((string)$a), void)
		$desc = [0 => ['pipe','r'], 1 => ['pipe','w'], 2 => ['pipe','w']]
		$proc = proc_open($cmd, $desc, $pipes)
		fclose($pipes[0])
		$children[$i] = obj(proc: $proc, out: $pipes[1], err: $pipes[2])
	}
	$results = []
	foreach ($children AS $i => $child){
		$out = stream_get_contents($child->out)
		$err = stream_get_contents($child->err)
		fclose($child->out)
		fclose($child->err)
		$code = proc_close($child->proc)
		$err = trim((string)$err)
		if ($err !== void){
			$ej = json_decode($err, true)
			$results[$i] = json_last_error() === JSON_ERROR_NONE ? $ej : $err
			continue
		}
		if ($code !== 0){
			$results[$i] = obj(error: 'CLI process failed', code: $code)
			continue
		}
		$json = json_decode($out, true)
		$results[$i] = json_last_error() === JSON_ERROR_NONE ? $json : $out
	}
	return $results
function

button(...$args):string

/phlo/resources/tags.form.phlo line 10
tag('button', ...$args)
function

camel(string $text)

/phlo/resources/camel.phlo line 7
lcfirst(str_replace(space, void, ucwords(lcfirst($text))))
function

chunk(...$cmds):void

/phlo/resources/chunk.phlo line 8
	$res = %res
	$cli = %req->cli
	if (debug){
		$res->dump && [$cmds['dump'] = $res->dump, $res->dump = []]
		$res->debug && [$cmds['debug'] = $res->debug, $res->debug = []]
	}
	if (!$res->streaming){
		$res->streaming = true
		$res->type = 'text/event-stream'
		$res->header('Cache-Control', 'no-store')
		$res->header('X-Content-Type-Options', 'nosniff')
		$res->render(206)
	}
	print(json_encode($cmds, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES).lf)
	$cli || [@ob_flush(), flush()]
function

create(iterable $items, \Closure $keyCb, ?\Closure $valueCb = null)

/phlo/resources/create.phlo line 7
array_combine(loop($items, $keyCb), $valueCb ? loop($items, $valueCb) : $items)
function

en($text, ...$args)

/phlo/resources/lang.phlo line 12
%lang->translation('en', $text, ...$args)
function

exec_stream(string $cmd, ?int $timeoutSec = 0)

/phlo/resources/exec.stream.phlo line 9
	$desc = [0 => ['pipe','r'], 1 => ['pipe','w'], 2 => ['pipe','w']]
	$proc = proc_open($cmd, $desc, $pipes)
	if (!is_resource($proc)) return
	stream_set_blocking($pipes[1], false)
	stream_set_blocking($pipes[2], false)
	$bufOut = void
	$bufErr = void
	while (true){
		$status = proc_get_status($proc)
		$running = $status['running']
		$read = []
		$w = null
		$e = null
		if (!feof($pipes[1])) $read[] = $pipes[1]
		if (!feof($pipes[2])) $read[] = $pipes[2]
		if ($read) @stream_select($read, $w, $e, 0, 200000)
		foreach ($read AS $r){
			$chunk = fread($r, 8192)
			if ($chunk === void || $chunk === false) continue
			if ($r === $pipes[1]){
				$bufOut .= $chunk
				while (($pos = strpos($bufOut, lf)) !== false){
					$line = substr($bufOut, 0, $pos)
					$bufOut = substr($bufOut, $pos + 1)
					yield obj(data: $line)
				}
			}
			else {
				$bufErr .= $chunk
				while (($pos = strpos($bufErr, lf)) !== false){
					$line = substr($bufErr, 0, $pos)
					$bufErr = substr($bufErr, $pos + 1)
					yield obj(data: $line, error: true)
				}
			}
		}
		if (!$running) break
		if ($timeoutSec > 0 && ($status['running_time'] ?? 0) > $timeoutSec){
			proc_terminate($proc)
			yield obj(data: 'process timeout', error: true)
			break
		}
	}
	if ($bufOut !== void) yield obj(data: $bufOut)
	if ($bufErr !== void) yield obj(data: $bufErr, error: true)
	foreach ($pipes AS $p) @fclose($p)
	proc_close($proc)
function

HTTP(string $url, array $headers = [], bool $JSON = false, $POST = null, $PUT = null, $PATCH = null, bool $DELETE = false, ?string $agent = null)

/phlo/resources/HTTP.phlo line 8
	$curl = curl_init($url)
	if ($POST !== null || $PUT !== null || $PATCH !== null){
		if (!is_null($POST)) [$method = 'POST', $content = $POST]
		elseif (!is_null($PUT)) [$method = 'PUT', $content = $PUT]
		elseif (!is_null($PATCH)) [$method = 'PATCH', $content = $PATCH]
		curl_setopt($curl, CURLOPT_CUSTOMREQUEST, $method)
		if ($JSON) [!is_string($content) && $content = json_encode($content), array_push($headers, 'Content-Type: application/json', 'Content-Length: '.strlen($content))]
		curl_setopt($curl, CURLOPT_POSTFIELDS, $content)
	}
	elseif ($DELETE) curl_setopt($curl, CURLOPT_CUSTOMREQUEST, 'DELETE')
	$agent && curl_setopt($curl, CURLOPT_USERAGENT, $agent === true ? phlo('req')->userAgent : $agent)
	curl_setopt_array($curl, [CURLOPT_COOKIEFILE => data.'cookies.txt', CURLOPT_COOKIEJAR => data.'cookies.txt', CURLOPT_HTTPHEADER => $headers, CURLOPT_FOLLOWLOCATION => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_TIMEOUT => 15, CURLOPT_ENCODING => void])
	$res = curl_exec($curl)
	if ($res === false) error('HTTP error: '.curl_error($curl))
	return $res
function

input(...$args):string

/phlo/resources/tags.form.phlo line 11
tag('input', ...$args)
function

n8n($webhook, ?array $data = null, $test = false)

/phlo/resources/n8n.phlo line 8
HTTP(%creds->n8n->server.'webhook'.($test ? '-test' : '').'/'.$webhook, POST: $data)
function

n8n_test($webhook, ?array $data = null)

/phlo/resources/n8n.test.phlo line 8
n8n($webhook, $data, true)
function

nl($text, ...$args)

/phlo/resources/lang.phlo line 11
%lang->translation('nl', $text, ...$args)
function

notify(string $title, string $body = '', string $type = 'info', string $level = 'info', ?string $user = null):void

/phlo/resources/notify.phlo line 9
	$cfg = %creds->notify ?? null
	if (!$cfg) return
	$url = (string)($cfg->url ?? void)
	$secret = (string)($cfg->secret ?? void)
	if ($url === void || $secret === void) return
	try {
		HTTP($url, ['secret: '.$secret], true, [
			'app' => (string)($cfg->app ?? (defined('id') ? id : 'app')),
			'server' => (string)($cfg->server ?? 'local'),
			'host' => (string)(%req->host ?? ''),
			'type' => $type,
			'level' => $level,
			'title' => $title,
			'body' => $body,
			'user' => $user,
		])
	}
	catch (\Throwable $e){}
function

phlo(?string $phloName = null, ...$args):mixed

/phlo/phlo.php line 190
static $list = [];
if ($phloName === 'tech/reset') return array_keys($list = array_filter($list, static fn($obj) => $obj->objPers));
if ($phloName === null) return array_keys($list);
$class = strtr($phloName, [slash => us]);
$handle = method_exists($class, '__handle') ? $class::__handle(...$args) : ($args ? null : $phloName);
if ($handle === true){
	if (isset($list[$phloName])) return $list[$phloName]->objImport(...$args);
	$handle = $phloName;
}
elseif ($handle && isset($list[$handle])) return $list[$handle];
$object = new $class(...$args);
if ($handle) $list[$handle] = $object;
if ($object->hasMethod('controller') && (!phlo('req')->cli || $phloName !== 'app')) $object->controller();
return $object;
function

phlo_app(...$args):void

/phlo/phlo.php line 38
if ($args['trace'] ??= false) require_once __DIR__.'/classes/trace.php';
require_once __DIR__.'/functions'.($args['trace'] ? '.trace.php' : '.php');
require_once __DIR__.'/classes/obj.php';
require_once __DIR__.'/classes/req.php';
require_once __DIR__.'/classes/res.php';
$args['app']       ??  error('No "app" path defined');
$args['debug']     ??= false;
$args['build']     ??= false;
$args['host']      ??= null;
$args['control']   ??= ($args['build'] && $args['debug']) ? 'phlo' : false;
$args['auth']      ??= false;
$args['data']      ??= $args['app'].'data/';
$args['php']       ??= $args['app'].'php/';
$args['www']       ??= $args['app'].'www/';
$args['cli']       ??= ZEND_THREAD_SAFE ? 'php-zts' : 'php';
$args['thread']    ??= false;
$args['build'] && $args['thread'] && error('Phlo build and thread mode cannot be combined');
$args['build'] && !is_file($args['data'].'app.json') && error('Phlo build mode requires data/app.json');
$args['auth'] && !$args['build'] && error('Auth requires build mode');
foreach ($args as $key => $value) define($key, $value);
define('engine', __DIR__.slash);
if ($args['debug']) require_once __DIR__.'/debug.php';
if ($args['build']) require_once __DIR__.'/classes/changed.php';
if ($args['trace']) trace::boot($args['app']);
set_error_handler(static function(int $level, string $msg, string $file = '', int $line = 0):bool {
	if (!(error_reporting() & $level)) return false;
	throw new ErrorException($msg, 0, $level, $file, $line);
});
set_exception_handler('phlo_exception');
spl_autoload_register(static function(string $class):void {
	static $map = null, $mtime = null;
	$file = php.'classmap.php';
	if ($map === null || $mtime !== (is_file($file) ? filemtime($file) : null)){
		$map   = is_file($file) ? require $file : [];
		$mtime = is_file($file) ? filemtime($file) : null;
	}
	if (isset($map[$class])){ require_once php.$map[$class]; return; }
});
if ($args['build']){
	$engineMap = ['build' => 'build', 'reflect' => 'reflect', 'build_file' => 'file', 'build_node' => 'node', 'build_builder' => 'builder', 'build_css' => 'css', 'build_icons' => 'icons'];
	spl_autoload_register(static function(string $class) use ($engineMap):void {
		$name = $engineMap[strtolower($class)] ?? null;
		if ($name !== null) require_once engine.'classes/'.$name.'.php';
	});
}
defined('composer') && spl_autoload_register(static function(string $class):void {
	static $loaded = false;
	if ($loaded) return;
	$loaded = true;
	require_once composer.'vendor/autoload.php';
	foreach (spl_autoload_functions() as $fn){
		if (is_array($fn) && ($fn[0] ?? null) instanceof \Composer\Autoload\ClassLoader){
			spl_autoload_unregister($fn);
			spl_autoload_register($fn);
			$fn[0]->loadClass($class);
			return;
		}
	}
});
if ($args['thread'] !== false && PHP_SAPI !== 'cli'){
	ignore_user_abort(true);
	$handle = static function():void { phlo_thread(); };
	for ($i = 1; !$args['thread'] || $i <= $args['thread']; ++$i){
		$keepRunning = frankenphp_handle_request($handle);
		phlo('tech/reset');
		if (session_status() === PHP_SESSION_ACTIVE) session_write_close();
		gc_collect_cycles();
		if (!$keepRunning) break;
	}
	return;
}
phlo_thread();
function

phlo_async(string $cb, ...$args)

/phlo/resources/phlo.async.phlo line 8
last($cmd = cli.space.escapeshellarg((string)$_SERVER['SCRIPT_FILENAME']).space.escapeshellarg($cb).loop($args, fn($a) => space.escapeshellarg((string)$a), void).' > /dev/null 2>&1 & echo $!', exec($cmd, $r), isset($r[0]) && ctype_digit($r[0]) && (int)$r[0] > 0)
function

phlo_cli(array $args):void

/phlo/phlo.php line 174
if (!$args) return;
$target = array_shift($args);
if (str_contains($target, dot)){
	[$object, $method] = explode(dot, $target, 2);
	$handle = phlo($object);
	$result = $args ? $handle->$method(...$args) : ($handle->hasMethod($method) ? $handle->$method() : $handle->$method);
}
elseif (str_contains($target, '::')){
	[$class, $method] = explode('::', $target, 2);
	$result = $class::$method(...$args);
}
else $result = $target(...$args);
if (isset($result)) print(json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES).lf);
function

phlo_exception(Throwable $e):void

/phlo/phlo.php line 33
require_once engine.'error.php';
phlo_error_handle($e);
function

phlo_exists(string $obj)

/phlo/resources/phlo.exists.phlo line 7
is_file(php.strtr($obj, [us => dot]).'.php')
function

phlo_load(bool $http):void

/phlo/phlo.php line 151
static $loaded = false, $loadedApp = null;
if ($loaded && $loadedApp === app){
	if ($http && !phlo('res')->type) phlo('res')->type = 'text/html; charset=UTF-8';
	return;
}
if (build && (!is_file(php.'functions.php') || !is_file(php.'app.php') || build_base::changed())){
	debug('Builder started');
	$changed = build::run();
	$changed && debug('Built '.implode(', ', array_map('basename', $changed)).' ('.count($changed).')');
}
if (!is_file(php.'functions.php') || !is_file(php.'app.php')) error('Compiled runtime not available');
if (!$loaded){
	require_once php.'functions.php';
	$loaded = true;
}
if ($loadedApp !== app){
	require_once php.'app.php';
	$loadedApp = app;
}
if ($http && !phlo('res')->type) phlo('res')->type = 'text/html; charset=UTF-8';
function

phlo_stream(string $cb, ...$args)

/phlo/resources/phlo.stream.phlo line 10
yield from exec_stream(cli.space.escapeshellarg((string)$_SERVER['SCRIPT_FILENAME']).space.escapeshellarg($cb).loop($args, fn($a) => space.escapeshellarg((string)$a), void))
function

phlo_sync(string $cb, ...$args)

/phlo/resources/phlo.sync.phlo line 8
	$cmd = cli.space.escapeshellarg((string)$_SERVER['SCRIPT_FILENAME']).space.escapeshellarg($cb).loop($args, fn($a) => space.escapeshellarg((string)$a), void)
	exec($cmd.' 2>&1', $r, $code)
	$out = implode(lf, $r)
	$j = json_decode($out, true)
	if ($code !== 0) error('Could not execute "'.esc($cb).'" via CLI')
	if (json_last_error() !== JSON_ERROR_NONE) return $out
	if (is_array($j) && isset($j['error'])) error((string)$j['error'])
	return $j
function

phlo_thread():void

/phlo/phlo.php line 113
try {
	$req = phlo('req');
	if ($req->cli){
		$target = $req->args[0] ?? void;
		if (str_starts_with($target, 'build::') || str_starts_with($target, 'reflect::')){
			phlo_cli($req->args);
			return;
		}
		phlo_load(false);
		phlo('app');
		phlo_cli($req->args);
		return;
	}
	$isDashboard = build && debug && control && str_starts_with($req->path.slash, control.slash);
	if (auth && !$isDashboard){
		phlo_auth('site', 'Phlo App - '.host);
		if (phlo('res')->done) return;
	}
	if ($isDashboard){
		require_once engine.'control.php';
		phlo_dashboard::handle(substr($req->path, strlen(control) + 1));
		phlo('res')->render();
		return;
	}
	phlo_load(true);
	phlo('app');
	phlo('res')->render();
}
catch (RuntimeException $e){
	if ($e->getMessage() === 'PhloDump' || $e->getMessage() === 'PhloStop') return;
	phlo_exception($e);
}
catch (Throwable $e){
	phlo_exception($e);
}
function

select(...$args):string

/phlo/resources/tags.form.phlo line 12
tag('select', ...$args)
function

slug(string $text)

/phlo/resources/slug.phlo line 7
trim(preg_replace('/[^a-z0-9]+/', dash, strtolower(iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $text))), dash)
function

tag(string $tagName, ?string $inner = null, ...$args)

/phlo/resources/tag.phlo line 8
"<$tagName".loop(array_filter($args, fn($value) => !is_null($value)), fn($value, $key) => space.strtr($key, [us => dash]).($value === true ? void : '="'.esc($value).'"'), void).'>'.(is_null($inner) ? void : "$inner</$tagName>")
function

textarea(...$args):string

/phlo/resources/tags.form.phlo line 13
tag('textarea', ...$args)
function

time_human(?int $time = null)

/phlo/resources/time.human.phlo line 7
	static $labels
	$labels ??= last($labels = arr(seconds: 60, minutes: 60, hours: 24, days: 7, weeks: 4, months: 13, years: 1), defined('tsLabels') && $labels = array_combine(tsLabels, $labels), $labels)
	$age = time() - $time
	foreach ($labels AS $range => $multiplier){
		if ($age / $multiplier < 1.6583) break
		$age /= $multiplier
	}
	return round($age)." $range"
function

wsCast($wsTarget = 'all', $wsHost = host, $wsPort = websocket, ...$data)

/phlo/resources/wsCast.phlo line 8
HTTP (
	'http://127.0.0.1:'.$wsPort.'/message',
	JSON: true,
	POST: arr (
		host: $wsHost,
		target: $wsTarget,
		data: $data,
	),
)

AI

object

%AI

/phlo/resources/AI/AI.phlo
version 1.0
creator q-ai.nl
summary Unified AI facade with engine auto-detect
package ai
frontend false
backend true
requires @OpenAI? @Claude? @Gemini? @DeepSeek?
tags ai facade llm streaming tools embeddings
const

AI :: engines

line 10
['claude' => 'Claude', 'gpt' => 'OpenAI', 'chatgpt' => 'OpenAI', 'o1' => 'OpenAI', 'o3' => 'OpenAI', 'o4' => 'OpenAI', 'deepseek' => 'DeepSeek', 'gemini' => 'Gemini']
static

AI :: sseDecodeEvents (string &$buffer, string $chunk):array

line 11
$buffer .= strtr($chunk, ["\r\n" => lf, "\r" => lf])
$events = []
while (($pos = strpos($buffer, lf.lf)) !== false){
	$raw = substr($buffer, 0, $pos)
	$buffer = substr($buffer, $pos + 2)
	$dataLines = []
	foreach (explode(lf, $raw) AS $line){
		$line = ltrim($line)
		if (!str_starts_with($line, 'data:')) continue
		$dataLines[] = ltrim(substr($line, 5))
	}
	if (!$dataLines) continue
	$payload = trim(implode(lf, $dataLines))
	if ($payload === '[DONE]' || $payload === void) continue
	$decoded = json_decode($payload)
	$decoded && $events[] = $decoded
}
return $events
static

AI :: sseStreamJSONWithTool (string $url, array $headers, array $payload, \Closure $extractText, \Closure $extractTool, ?\Closure $cb = null, ?\Closure $toolCb = null):obj

line 31
$req = %req
if (!$cb){
	$req->cli || %res->header('Content-Type', 'text/event-stream')
	$cb = fn($text) => last(print($text), $req->cli || [@ob_flush(), flush()], $text)
}
$answer = void
$toolJson = void
$buffer = void
$curl = curl_init($url)
$json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
curl_setopt($curl, CURLOPT_CUSTOMREQUEST, 'POST')
curl_setopt($curl, CURLOPT_POSTFIELDS, $json)
curl_setopt($curl, CURLOPT_HTTPHEADER, [...$headers, 'Content-Type: application/json', 'Content-Length: '.strlen($json)])
curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, 5)
curl_setopt($curl, CURLOPT_WRITEFUNCTION, function($curl, $data) use ($extractText, $extractTool, $cb, $toolCb, &$buffer, &$answer, &$toolJson){
	foreach (static::sseDecodeEvents($buffer, $data) AS $event){
		$text = $extractText($event)
		(is_null($text) || $text === void || $text === false) || $answer .= $cb((string)$text, $event)
		$tool = $extractTool($event)
		if (!(is_null($tool) || $tool === void || $tool === false)){
			$toolJson .= (string)$tool
			if ($toolCb) $toolCb((string)$tool)
		}
	}
	return strlen($data)
})
$res = curl_exec($curl)
$res === false && error('HTTP error: '.curl_error($curl))
return new obj(answer: $answer, tool_json: $toolJson)
static

AI :: http (string $url, array $headers, bool $json = true, mixed $post = null):string

line 62
$curl = curl_init($url)
if ($post !== null){
	curl_setopt($curl, CURLOPT_CUSTOMREQUEST, 'POST')
	if ($json && !is_string($post)) $post = json_encode($post, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
	if ($json) $headers[] = 'Content-Type: application/json'
	curl_setopt($curl, CURLOPT_POSTFIELDS, $post)
}
curl_setopt_array($curl, [CURLOPT_HTTPHEADER => $headers, CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_TIMEOUT => 300, CURLOPT_ENCODING => ''])
$res = curl_exec($curl)
if ($res === false) throw new \RuntimeException('HTTP error: '.curl_error($curl))
return $res
static

AI :: resolve (...$args):array

line 75
$via = $args['via'] ?? void
unset($args['via'])
$args['model'] ??= %app->model ?? void
if ($via) $via = static::engines[strtolower($via)] ?? $via
elseif (isset($args['model'])) $via = static::engines[strtolower(explode('-', $args['model'])[0])] ?? void
return [$via ?: 'OpenAI', $args]
method

%AI -> chat (...$args)

line 83
[$engine, $args] = static::resolve(...$args)
return phlo($engine)->chat(...$args)
method

%AI -> stream (...$args):Generator

line 87
[$engine, $args] = static::resolve(...$args)
return phlo($engine)->stream(...$args)
method

%AI -> stream_with_tool (...$args):obj

line 91
[$engine, $args] = static::resolve(...$args)
return phlo($engine)->stream_with_tool(...$args)
method

%AI -> embedding (...$args)

line 95
[$engine, $args] = static::resolve(...$args)
return phlo($engine)->embedding(...$args)
method

%AI -> vision (...$args)

line 99
[$engine, $args] = static::resolve(...$args)
return phlo($engine)->vision(...$args)
method

%AI -> transcribe (...$args)

line 103
[$engine, $args] = static::resolve(...$args)
return phlo($engine)->transcribe(...$args)
object

%Claude

/phlo/resources/AI/Claude.phlo
version 1.1
creator q-ai.nl
summary Anthropic Claude API
package ai
frontend false
backend true
requires creds:Claude @AI
tags ai claude anthropic chat vision embeddings
const

Claude :: model

line 10
'claude-sonnet-4-5-20250929'
static

Claude :: context (...$args)

line 12
$args['messages'] ??= []
if (isset($args['system'])){
	$args['system'] = [['type' => 'text', 'text' => $args['system']]]
}
if (isset($args['assistant']) && array_push($args['messages'], ['role' => 'assistant', 'content' => $args['assistant']])) unset($args['assistant'])
if (isset($args['user']) && array_push($args['messages'], ['role' => 'user', 'content' => $args['user']])) unset($args['user'])
return $args
static

Claude :: tool ($tool):array

line 22
[
	'name' => $tool->name,
	'description' => $tool->desc,
	'input_schema' => [
		'type' => 'object',
		'properties' => loop($tool->args, fn($data, $arg) => array_filter($data, fn($key) => in_array($key, ['type', 'enum', 'description']), ARRAY_FILTER_USE_KEY)),
		'required' => array_keys($tool->args),
	],
]
method

%Claude -> embedding ($input, $model = 'text-embedding-3-small')

line 32
%OpenAI->embedding($input, $model)
method

%Claude -> vision ($text, $image, $stream = false, ...$args)

line 34
$data = is_string($image) && str_starts_with($image, 'http') ? file_get_contents($image) : $image
$messages = [['role' => 'user', 'content' => [['type' => 'text', 'text' => $text], ['type' => 'image', 'source' => ['type' => 'base64', 'media_type' => 'image/jpeg', 'data' => base64_encode($data)]]]]]
if ($stream) return $this->stream(...$args, messages: $messages)
else return $this->chat(...$args, messages: $messages)
method

%Claude -> chat (...$args)

line 41
$args['model'] ??= static::model
$args['max_tokens'] ??= 4096
$token = $args['token'] ?? null
unset($args['token'])
$args = static::context(...$args)
$res = $this->request('messages', token: $token, POST: $args)
$return = new obj(answer: void, model: $res->model, finish: $res->stop_reason, tokens: ($res->usage->input_tokens ?? 0) + ($res->usage->output_tokens ?? 0), tokens_in: $res->usage->input_tokens ?? 0, tokens_out: $res->usage->output_tokens ?? 0)
$tools = []
foreach ($res->content AS $block){
	if ($block->type === 'text') $return->answer = $block->text
	elseif ($block->type === 'tool_use') $tools[] = new obj(name: $block->name, args: (array)$block->input)
}
if ($tools) $return->tools = $tools
return $return
method

%Claude -> parseSSE (string $url, array $headers, array $payload):Generator

line 58
$json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
$ctx = stream_context_create(['http' => ['method' => 'POST', 'header' => implode("\r\n", [...$headers, 'Content-Type: application/json', 'Content-Length: '.strlen($json)]), 'content' => $json, 'timeout' => 300, 'ignore_errors' => true]])
$stream = fopen($url, 'r', false, $ctx)
$stream || error('SSE connection failed')
$status = (int)explode(' ', $http_response_header[0] ?? 'HTTP/1.1 200')[1]
if ($status >= 400){
	$err = json_decode((string)stream_get_contents($stream))
	fclose($stream)
	error('Claude error '.$status.': '.($err->error->message ?? 'unknown'))
}
$finish = null
$usage = null
while (!feof($stream)){
	$line = fgets($stream)
	if ($line === false) break
	$line = rtrim($line, "\r\n")
	if (!str_starts_with($line, 'data:')) continue
	$data = ltrim(substr($line, 5))
	if ($data === void) continue
	$p = json_decode($data)
	if (!$p) continue
	if (isset($p->error)) error('Claude stream error: '.$p->error->message)
	if (($p->type ?? null) === 'message_delta'){
		$finish = $p->delta->stop_reason ?? $finish
		if (isset($p->usage)) $usage = $p->usage
	}
	if (($p->type ?? null) === 'content_block_delta' && ($p->delta->type ?? null) === 'text_delta' && isset($p->delta->text)) yield obj(text: $p->delta->text)
}
fclose($stream)
yield obj(done: true, finish: $finish, tokens_in: $usage?->input_tokens, tokens_out: $usage?->output_tokens)
method

%Claude -> stream (...$args):Generator

line 90
%app->streaming = true
$args['model'] ??= static::model
$args['max_tokens'] ??= 4096
$token = $args['token'] ?? null
unset($args['token'], $args['cb'])
$args = static::context(...$args)
$args['stream'] = true
if ($token) $headers = ['anthropic-version: 2023-06-01', 'anthropic-beta: oauth-2025-04-20', 'Authorization: Bearer '.$token]
else $headers = ['anthropic-version: 2023-06-01', 'x-api-key: '.%creds->Claude]
return $this->parseSSE('https://api.anthropic.com/v1/messages', $headers, $args)
method

%Claude -> request ($uri, ...$args)

line 103
$token = $args['token'] ?? null
if ($token) $headers = ['anthropic-version: 2023-06-01', 'anthropic-beta: oauth-2025-04-20', 'Authorization: Bearer '.$token]
else $headers = ['anthropic-version: 2023-06-01', 'x-api-key: '.%creds->Claude]
$res = json_decode(AI::http("https://api.anthropic.com/v1/$uri", $headers, true, $args['POST'] ?? null))
if (isset($res->error)) error('Claude Request error: '.$res->error->message)
return $res
object

%DeepSeek

/phlo/resources/AI/DeepSeek.phlo
version 1.1
creator q-ai.nl
summary DeepSeek API
package ai
frontend false
backend true
requires creds:DeepSeek @AI
tags ai deepseek chat embeddings
const

DeepSeek :: model

line 10
'deepseek-chat'
static

DeepSeek :: context (...$args)

line 12
$args['messages'] ??= []
if (isset($args['system']) && array_unshift($args['messages'], ['role' => 'system', 'content' => $args['system']])) unset($args['system'])
if (isset($args['assistant']) && array_push($args['messages'], ['role' => 'assistant', 'content' => $args['assistant']])) unset($args['assistant'])
if (isset($args['user']) && array_push($args['messages'], ['role' => 'user', 'content' => $args['user']])) unset($args['user'])
return $args
static

DeepSeek :: tool ($tool):array

line 20
[
	'type' => 'function',
	'function' => [
		'name' => $tool->name,
		'description' => $tool->desc,
		'parameters' => [
			'type' => 'object',
			'properties' => loop($tool->args, fn($data, $arg) => array_filter($data, fn($key) => in_array($key, ['type', 'enum', 'desc']), ARRAY_FILTER_USE_KEY)),
			'additionalProperties' => false,
			'required' => array_keys($tool->args),
		],
		'strict' => true,
	],
]
method

%DeepSeek -> embedding ($input, $model = 'text-embedding-3-small')

line 35
%OpenAI->embedding($input, $model)
method

%DeepSeek -> chat (...$args)

line 37
$args['model'] ??= static::model
$args = static::context(...$args)
$res = $this->request('chat/completions', POST: $args)
$return = new obj(answer: $res->choices[0]->message->content, model: $res->model, finish: $res->choices[0]->finish_reason, tokens: $res->usage->total_tokens, tokens_in: $res->usage->prompt_tokens, tokens_out: $res->usage->completion_tokens)
if (isset($res->choices[0]->message->tool_calls)) $return->tools = array_map(fn($tool) => new obj(name: $tool->function->name, args: json_decode($tool->function->arguments, true)), (array)$res->choices[0]->message->tool_calls)
return $return
method

%DeepSeek -> parseSSE (string $url, array $headers, array $payload):Generator

line 46
$json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
$ctx = stream_context_create(['http' => ['method' => 'POST', 'header' => implode("\r\n", [...$headers, 'Content-Type: application/json', 'Content-Length: '.strlen($json)]), 'content' => $json, 'timeout' => 300, 'ignore_errors' => true]])
$stream = fopen($url, 'r', false, $ctx)
$stream || error('SSE connection failed')
$status = (int)explode(' ', $http_response_header[0] ?? 'HTTP/1.1 200')[1]
if ($status >= 400){
	$err = json_decode((string)stream_get_contents($stream))
	fclose($stream)
	error('DeepSeek error '.$status.': '.($err->error->message ?? 'unknown'))
}
$finish = null
$usage = null
while (!feof($stream)){
	$line = fgets($stream)
	if ($line === false) break
	$line = rtrim($line, "\r\n")
	if (!str_starts_with($line, 'data:')) continue
	$data = ltrim(substr($line, 5))
	if ($data === '[DONE]' || $data === void) continue
	$p = json_decode($data)
	if (!$p) continue
	if (isset($p->error)) error('DeepSeek stream error: '.$p->error->message)
	if (isset($p->usage)) $usage = $p->usage
	$text = $p->choices[0]->delta->content ?? null
	if (!is_null($text)) yield obj(text: $text)
	if ($p->choices[0]->finish_reason ?? null) $finish = $p->choices[0]->finish_reason
}
fclose($stream)
yield obj(done: true, finish: $finish, tokens_in: $usage?->prompt_tokens, tokens_out: $usage?->completion_tokens)
method

%DeepSeek -> stream (...$args):Generator

line 77
%app->streaming = true
$args['model'] ??= static::model
unset($args['cb'])
$args = static::context(...$args)
$args['stream'] = true
return $this->parseSSE('https://api.deepseek.com/v1/chat/completions', ['Authorization: Bearer '.%creds->DeepSeek], $args)
method

%DeepSeek -> request ($uri, $JSON = true, ...$args)

line 86
$res = json_decode(AI::http("https://api.deepseek.com/v1/$uri", ['Authorization: Bearer '.%creds->DeepSeek], $JSON, $args['POST'] ?? null))
if (isset($res->error)) error('DeepSeek Request error: '.$res->error->message)
return $res
object

%Gemini

/phlo/resources/AI/Gemini.phlo
version 1.1
creator q-ai.nl
summary Google Gemini API
package ai
frontend false
backend true
requires creds:Gemini @AI
tags ai gemini google chat vision embeddings
const

Gemini :: model

line 10
'gemini-2.0-flash'
const

Gemini :: endpoint

line 11
'https://generativelanguage.googleapis.com/v1beta/models/'
static

Gemini :: context (...$args)

line 13
$args['contents'] ??= []
if (isset($args['system'])){
	$args['systemInstruction'] = ['parts' => [['text' => $args['system']]]]
	unset($args['system'])
}
if (isset($args['assistant']) && array_push($args['contents'], ['role' => 'model', 'parts' => [['text' => $args['assistant']]]])) unset($args['assistant'])
if (isset($args['user']) && array_push($args['contents'], ['role' => 'user', 'parts' => [['text' => $args['user']]]])) unset($args['user'])
return $args
static

Gemini :: tool ($tool):array

line 24
[
	'name' => $tool->name,
	'description' => $tool->desc,
	'parameters' => [
		'type' => 'object',
		'properties' => loop($tool->args, fn($data, $arg) => array_filter($data, fn($key) => in_array($key, ['type', 'enum', 'description']), ARRAY_FILTER_USE_KEY)),
		'required' => array_keys($tool->args),
	],
]
method

%Gemini -> embedding ($input, $model = 'text-embedding-004')

line 34
$this->request($model.':embedContent', POST: ['content' => ['parts' => [['text' => $input]]]])->embedding->values
method

%Gemini -> vision ($text, $image, $stream = false, ...$args)

line 36
$model = $args['model'] ?? static::model
unset($args['model'])
$contents = [['role' => 'user', 'parts' => [['text' => $text], ['inline_data' => ['mime_type' => 'image/jpeg', 'data' => base64_encode(is_string($image) && str_starts_with($image, 'http') ? file_get_contents($image) : $image)]]]]]
if ($stream) return $this->stream(...$args, model: $model, contents: $contents)
else return $this->chat(...$args, model: $model, contents: $contents)
method

%Gemini -> chat (...$args)

line 44
$model = $args['model'] ?? static::model
unset($args['model'])
$args = static::context(...$args)
$res = $this->request($model.':generateContent', POST: $args)
$return = new obj(answer: void, model: $model, finish: $res->candidates[0]->finishReason ?? void, tokens: ($res->usageMetadata->promptTokenCount ?? 0) + ($res->usageMetadata->candidatesTokenCount ?? 0), tokens_in: $res->usageMetadata->promptTokenCount ?? 0, tokens_out: $res->usageMetadata->candidatesTokenCount ?? 0)
$tools = []
foreach ($res->candidates[0]->content->parts ?? [] AS $part){
	if (isset($part->text)) $return->answer = $part->text
	elseif (isset($part->functionCall)) $tools[] = new obj(name: $part->functionCall->name, args: (array)$part->functionCall->args)
}
if ($tools) $return->tools = $tools
return $return
method

%Gemini -> parseSSE (string $url, array $headers, array $payload):Generator

line 59
$json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
$ctx = stream_context_create(['http' => ['method' => 'POST', 'header' => implode("\r\n", [...$headers, 'Content-Type: application/json', 'Content-Length: '.strlen($json)]), 'content' => $json, 'timeout' => 300, 'ignore_errors' => true]])
$stream = fopen($url, 'r', false, $ctx)
$stream || error('SSE connection failed')
$status = (int)explode(' ', $http_response_header[0] ?? 'HTTP/1.1 200')[1]
if ($status >= 400){
	$err = json_decode((string)stream_get_contents($stream))
	fclose($stream)
	error('Gemini error '.$status.': '.($err->error->message ?? 'unknown'))
}
$finish = null
$usage = null
while (!feof($stream)){
	$line = fgets($stream)
	if ($line === false) break
	$line = rtrim($line, "\r\n")
	if (!str_starts_with($line, 'data:')) continue
	$data = ltrim(substr($line, 5))
	if ($data === void) continue
	$p = json_decode($data)
	if (!$p) continue
	if (isset($p->error)) error('Gemini stream error: '.$p->error->message)
	if (isset($p->usageMetadata)) $usage = $p->usageMetadata
	if ($p->candidates[0]->finishReason ?? null) $finish = $p->candidates[0]->finishReason
	$text = $p->candidates[0]->content->parts[0]->text ?? null
	if (!is_null($text)) yield obj(text: $text)
}
fclose($stream)
yield obj(done: true, finish: $finish, tokens_in: $usage?->promptTokenCount, tokens_out: $usage?->candidatesTokenCount)
method

%Gemini -> stream (...$args):Generator

line 90
%app->streaming = true
$model = $args['model'] ?? static::model
unset($args['model'], $args['cb'])
$args = static::context(...$args)
return $this->parseSSE(static::endpoint.$model.':streamGenerateContent?alt=sse', ['x-goog-api-key: '.%creds->Gemini], $args)
method

%Gemini -> request ($path, ...$args)

line 98
$res = json_decode(AI::http(static::endpoint.$path, ['x-goog-api-key: '.%creds->Gemini], true, $args['POST'] ?? null))
if (isset($res->error)) error('Gemini Request error: '.$res->error->message)
return $res
object

%OpenAI

/phlo/resources/AI/OpenAI.phlo
version 1.0
creator q-ai.nl
summary Basic OpenAI functions
package ai
frontend false
backend true
requires creds:OpenAI @AI
tags ai openai llm chat embeddings audio vision
const

OpenAI :: model

line 10
'gpt-4o-mini'
const

OpenAI :: voices

line 11
['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer']
static

OpenAI :: context (...$args):array

line 12
$args['messages'] ??= []
if (isset($args['system']) && array_unshift($args['messages'], ['role' => 'system', 'content' => $args['system']])) unset($args['system'])
if (isset($args['assistant']) && array_push($args['messages'], ['role' => 'assistant', 'content' => $args['assistant']])) unset($args['assistant'])
if (isset($args['user']) && array_push($args['messages'], ['role' => 'user', 'content' => $args['user']])) unset($args['user'])
return $args
static

OpenAI :: tool ($tool):array

line 19
[
	'type' => 'function',
	'function' => [
		'name' => $tool->name,
		'description' => $tool->desc,
		'parameters' => [
			'type' => 'object',
			'properties' => loop($tool->args, fn($data, $arg) => array_filter($data, fn($key) => in_array($key, ['type', 'enum', 'desc']), ARRAY_FILTER_USE_KEY)),
			'additionalProperties' => false,
			'required' => array_keys($tool->args),
		],
		'strict' => true,
	],
]
method

%OpenAI -> chat (...$args):obj

line 33
$args['model'] ??= static::model
$token = $args['token'] ?? null
unset($args['token'])
$args = static::context(...$args)
$res = $this->request('chat/completions', token: $token, POST: $args)
$return = new obj(answer: $res->choices[0]->message->content, model: $res->model, finish: $res->choices[0]->finish_reason, tokens: $res->usage->total_tokens, tokens_in: $res->usage->prompt_tokens, tokens_out: $res->usage->completion_tokens)
if (isset($res->choices[0]->message->tool_calls)) $return->tools = array_map(fn($tool) => new obj(name: $tool->function->name, args: json_decode($tool->function->arguments, true)), (array)$res->choices[0]->message->tool_calls)
return $return
method

%OpenAI -> embedding ($input, $model = 'text-embedding-3-small')

line 43
$this->request('embeddings', POST: ['input' => $input, 'model' => $model])->data[0]->embedding
method

%OpenAI -> parseSSE (string $url, array $headers, array $payload):Generator

line 44
$json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
$ctx = stream_context_create(['http' => ['method' => 'POST', 'header' => implode("\r\n", [...$headers, 'Content-Type: application/json', 'Content-Length: '.strlen($json)]), 'content' => $json, 'timeout' => 300, 'ignore_errors' => true]])
$stream = fopen($url, 'r', false, $ctx)
$stream || error('SSE connection failed')
$status = (int)explode(' ', $http_response_header[0] ?? 'HTTP/1.1 200')[1]
if ($status >= 400){
	$err = json_decode((string)stream_get_contents($stream))
	fclose($stream)
	error('OpenAI error '.$status.': '.($err->error->message ?? 'unknown'))
}
$finish = null
$usage = null
while (!feof($stream)){
	$line = fgets($stream)
	if ($line === false) break
	$line = rtrim($line, "\r\n")
	if (!str_starts_with($line, 'data:')) continue
	$data = ltrim(substr($line, 5))
	if ($data === '[DONE]' || $data === void) continue
	$p = json_decode($data)
	if (!$p) continue
	if (isset($p->error)) error('OpenAI stream error: '.$p->error->message)
	if (isset($p->usage)) $usage = $p->usage
	$text = $p->choices[0]->delta->content ?? null
	if (!is_null($text)) yield obj(text: $text)
	if ($p->choices[0]->finish_reason ?? null) $finish = $p->choices[0]->finish_reason
}
fclose($stream)
yield obj(done: true, finish: $finish, tokens_in: $usage?->prompt_tokens, tokens_out: $usage?->completion_tokens)
method

%OpenAI -> stream (...$args):Generator

line 75
$args['model'] ??= static::model
$token = $args['token'] ?? null
unset($args['token'], $args['cb'])
$args = static::context(...$args)
$args['stream'] = true
$bearer = $token ?? %creds->OpenAI
return $this->parseSSE('https://api.openai.com/v1/chat/completions', ['Authorization: Bearer '.$bearer], $args)
method

%OpenAI -> transcribe ($file, $model = 'whisper-1', ...$args):obj

line 84
if (is_string($file)) $file = new CURLFile($file)
elseif (is_a($file, 'file')) $file = $file->curl
$res = $this->request('audio/transcriptions', false, POST: [...$args, 'model' => $model, 'file' => $file, 'response_format' => 'verbose_json'])
return obj (
	model: $model,
	duration: $res->duration,
	lang: $res->language,
	text: $res->text,
)
method

%OpenAI -> vision ($text, $image, $stream = false, ...$args):obj

line 95
$args['model'] ??= static::model
$messages = [['role' => 'user', 'content' => [['type' => 'text', 'text' => $text], ['type' => 'image_url', 'image_url' => ['url' => $image]]]]]
if ($stream) return $this->stream(...$args, messages: $messages)
else return $this->chat(...$args, messages: $messages)
method

%OpenAI -> request ($uri, $JSON = true, $token = null, ...$args)

line 101
$bearer = $token ?? %creds->OpenAI
$res = json_decode(AI::http("https://api.openai.com/v1/$uri", ['Authorization: Bearer '.$bearer], $JSON, $args['POST'] ?? null))
if (isset($res->error)) error('OpenAI Request error: '.$res->error->message)
return $res

Functions

function

answer($question, ...$options)

/phlo/resources/AI/answer.phlo line 10
	$prompt = 'You are an AI answer machine. '
	$prompt .= 'You give short, direct answers without repeating the subject. '
	$prompt .= 'You add no explanation, no extra text, and no quotation marks. '
	$prompt .= 'Always answer in the same language as the question.'.lf
	if ($options){
		$prompt .= 'The user asks a question. You choose exactly one of the options below as the answer. '
		$prompt .= 'Your answer matches exactly one of the options, with no extra words or punctuation. '
		$prompt .= 'If none of the options apply, answer with "-".'.lf.lf
		$prompt .= 'Question:'.lf.$question.lf.lf
		$prompt .= 'Options:'.lf
		$prompt .= implode(lf, $options)
	}
	else {
		$prompt .= 'Give a short and precise answer to the question. '
		$prompt .= 'No introduction, no explanation, no list, and no final period.'.lf.lf
		$prompt .= 'Question:'.lf.$question
	}
	$answer = %OpenAI->chat(user: $prompt, temperature: .1)->answer
	return $answer === dash ? null : $answer

DB

object

%DB

/phlo/resources/DB/DB.phlo
version 1.0
creator q-ai.nl
summary Database engine class
type abstract class
package database
frontend false
backend true
requires php-ext:pdo
tags database pdo sql
prop

%DB -> PDO

line 11
error('No PDO connector defined')
prop

%DB -> fieldQuotes

line 12
bt
method

%DB -> load (string $table, string $columns = '*', string $where = void, string $joins = void, string $group = void, string $limit = void, string $order = void, ...$args)

line 14
$table = strpos($table, space) || strpos($table, dot) ? $table : "$this->fieldQuotes$table$this->fieldQuotes"
!$where && $args && $where = loop(array_keys($args), fn($column) => "$table.$column=?", ' AND ')
$joins && $joins = " $joins"
$where && $where = " WHERE $where"
$group && $group = " GROUP BY $group"
$order && $order = " ORDER BY $order"
$limit && $limit = " LIMIT $limit"
$query = "SELECT $columns FROM $table$joins$where$group$order$limit"
return $this->query($query, ...array_values($args))
method

%DB -> query ($query, ...$args)

line 26
try {
	if (!$args) $stmt = $this->PDO->query($query)
	else {
		$stmt = $this->PDO->prepare($query)
		$stmt->execute($args)
	}
	if (debug){
		$match = regex('/\b(UPDATE|INSERT INTO|DELETE FROM|FROM)\b\s+([`"\[]?\w+[`"\]]?)/i', strtr($query, [$this->fieldQuotes => void]))
		$where = strtr(regex('/\bWHERE (\b.+)/is', $query)[1] ?? void, [' ORDER BY' => void])
		$match && debug("Q: $match[1] $match[2]".strtr(rtrim(" $where "), [dq => void])." (".$stmt->rowCount().")")
	}
}
catch (\PDOException $e){
	error('Database error'.colon.lf.$query.lf.lf.$e->getMessage())
}
return $stmt
method

%DB -> column (...$args)

line 45
$this->load(...$args)->fetchAll(\PDO::FETCH_COLUMN)
method

%DB -> item (...$args)

line 46
$this->load(...$args)->fetch(\PDO::FETCH_COLUMN) ?: null
method

%DB -> pair (...$args)

line 47
$this->load(...$args)->fetchAll(\PDO::FETCH_KEY_PAIR)
method

%DB -> group (...$args)

line 48
$this->load(...$args)->fetchAll(\PDO::FETCH_GROUP|\PDO::FETCH_CLASS, obj::class)
method

%DB -> records (...$args)

line 49
$this->load(...$args)->fetchAll(\PDO::FETCH_CLASS|\PDO::FETCH_UNIQUE, obj::class)
method

%DB -> rows (...$args)

line 50
$this->load(...$args)->fetchAll(\PDO::FETCH_CLASS, obj::class)
method

%DB -> record (...$args)

line 51
$this->load(...$args)->fetchObject(obj::class) ?: null
method

%DB -> create (string $table, ...$data)

line 53
if ($ignore = $data['ignore'] ?? false) unset($data['ignore'])
$columns = $this->fieldQuotes.implode($this->fieldQuotes.comma.$this->fieldQuotes, array_keys($data)).$this->fieldQuotes
$values = implode(comma, array_fill(0, count($data), qm))
$query = "INSERT".($ignore ? ' IGNORE' : void)." INTO $table ($columns) VALUES ($values)"
$this->query($query, ...array_values(loop($data, fn($value) => is_a($value, obj::class) ? $value->id : $value)))
return $this->PDO->lastInsertId() ?: ($data['id'] ?? null)
method

%DB -> change (string $table, string $where, ...$data)

line 62
$whereCount = substr_count($where, qm)
$updates = isset($data['updates']) ? $data['updates'] : void
unset($data['updates'])
$updates .= (($wheres = array_slice(array_keys($data), $whereCount)) && $updates ? comma : void).loop($wheres, fn($key) => $key.'=?', comma)
$query = "UPDATE $table SET $updates WHERE $where"
$args = array_values([...array_slice($data, $whereCount), ...array_slice($data, 0, $whereCount)])
return $this->query($query, ...$args)->rowCount()
method

%DB -> delete (string $table, string $where, ...$args)

line 72
$this->query("DELETE FROM $table WHERE $where", ...$args)->rowCount()
method

%DB -> begin

line 73
$this->PDO->beginTransaction()
method

%DB -> commit

line 74
$this->PDO->commit()
method

%DB -> rollback

line 75
$this->PDO->inTransaction() && $this->PDO->rollBack()
method

%DB -> transaction ($callback)

line 77
$this->begin
try {
	$result = $callback()
	$this->commit
	return $result
} catch (\Throwable $e){
	$this->rollback
	throw $e
}
object

%JSONDB

/phlo/resources/DB/JSONDB.phlo
version 1.2
creator q-ai.nl
summary JSON file database driver. One JSONDB instance = one JSON file = one model table. No joins, no transactions, no schema introspection.
extends DB
package database
frontend false
backend true
requires @DB @JSON_result
tags json database file storage
static

JSONDB :: __handle

line 11
"JSONDB/$file"
method

%JSONDB -> __construct (private string $file)

line 12
$dir = dirname($this->file)
is_dir($dir) || mkdir($dir, 0755, true) || error("JSONDB: cannot create dir $dir")
prop

%JSONDB -> PDO

line 16
error('JSONDB driver does not use PDO')
prop

%JSONDB -> fieldQuotes

line 17
''
prop

%JSONDB -> lastInsertedId

line 18
null
method

%JSONDB -> objRead

line 20
file_exists($this->file) ? json_decode(file_get_contents($this->file), true) ?: [] : []
method

%JSONDB -> objWrite (array $data)

line 21
file_put_contents($this->file, json_encode(array_values($data), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), LOCK_EX)
method

%JSONDB -> objNextId (array $data)

line 22
$data ? (int)max(array_column($data, 'id')) + 1 : 1
method

%JSONDB -> objFilter (array $data, string $where = void, ...$args)

line 24
if (!$where) return $data
$filtered = []
foreach ($data AS $row){
	$match = true
	$parts = preg_split('/\s+AND\s+/i', $where)
	$argIndex = 0
	foreach ($parts AS $part){
		if (preg_match('/^[`"]?(\w+)[`"]?\s*=\s*\?$/', trim($part), $m)){
			$column = $m[1]
			$value = $args[$argIndex++] ?? null
			if (($row[$column] ?? null) != $value) $match = false
		}
		elseif (preg_match('/^[`"]?(\w+)[`"]?\s+IN\s*\((.+)\)$/i', trim($part), $m)){
			$column = $m[1]
			$ids = array_map(fn($v) => trim($v, "\"' "), explode(',', $m[2]))
			if (!in_array($row[$column] ?? null, $ids)) $match = false
		}
	}
	$match && $filtered[] = $row
}
return $filtered
method

%JSONDB -> objSelect (string $where = void, string $limit = void, string $order = void, ...$args)

line 48
$data = $this->objFilter($this->objRead(), $where, ...array_values($args))
if ($order){
	$desc = str_contains($order, 'DESC')
	$col = trim(preg_replace('/\s+(ASC|DESC)/i', '', $order), '` ')
	usort($data, fn($a, $b) => $desc ? ($b[$col] ?? 0) <=> ($a[$col] ?? 0) : ($a[$col] ?? 0) <=> ($b[$col] ?? 0))
}
$limit && $data = array_slice($data, 0, (int)$limit)
return $data
method

%JSONDB -> create (string $table, ...$data)

line 59
if ($ignore = $data['ignore'] ?? false) unset($data['ignore'])
$all = $this->objRead()
$data['id'] ??= $this->objNextId($all)
foreach ($data AS $key => $value) is_a($value, 'obj') && $data[$key] = $value->id
if ($ignore){
	foreach ($all AS $row) if (($row['id'] ?? null) == $data['id']) return $data['id']
}
$all[] = $data
$this->objWrite($all)
$this->lastInsertedId = $data['id']
return $data['id']
method

%JSONDB -> change (string $table, string $where, ...$data)

line 73
$all = $this->objRead()
$whereCount = substr_count($where, '?')
$whereArgs = array_slice(array_values($data), 0, $whereCount)
$updates = array_slice($data, $whereCount, null, true)
$changed = 0
foreach ($all AS &$row){
	if ($this->objFilter([$row], $where, ...$whereArgs)){
		foreach ($updates AS $key => $value) $row[$key] = $value
		$changed++
	}
}
unset($row)
$this->objWrite($all)
return $changed
method

%JSONDB -> delete (string $table, string $where, ...$args)

line 90
$all = $this->objRead()
$matching = $this->objFilter($all, $where, ...$args)
$matchIds = array_column($matching, 'id')
$remaining = array_values(array_filter($all, fn($row) => !in_array($row['id'] ?? null, $matchIds)))
$this->objWrite($remaining)
return count($matching)
method

%JSONDB -> load (string $table, string $columns = '*', string $where = void, string $joins = void, string $group = void, string $limit = void, string $order = void, ...$args)

line 99
!$where && $args && $where = loop(array_keys($args), fn($column) => "$column=?", ' AND ')
$data = $this->objSelect($where, $limit, $order, ...array_values($args))
return %JSON_result($data)
method

%JSONDB -> query ($query, ...$args)

line 104
error('JSONDB driver does not support raw SQL queries')
method

%JSONDB -> begin

line 106
null
method

%JSONDB -> commit

line 107
null
method

%JSONDB -> rollback

line 108
null
object

%JSON_result

/phlo/resources/DB/JSON.result.phlo
version 1.0
creator q-ai.nl
summary Minimal PDOStatement-like wrapper for JSONDB result arrays
package database
frontend false
backend true
tags json database result
static

JSON_result :: __handle

line 9
null
prop

%JSON_result -> data

line 10
[]
method

%JSON_result -> __construct (array $data)

line 11
$this->data = $data
method

%JSON_result -> fetchAll ($mode = 2)

line 13
if ($mode === \PDO::FETCH_COLUMN) return loop($this->data, fn($row) => reset((array)$row))
if ($mode === \PDO::FETCH_KEY_PAIR){
	$out = []
	foreach ($this->data AS $row){
		$vals = array_values((array)$row)
		$out[$vals[0] ?? null] = $vals[1] ?? null
	}
	return $out
}
if (($mode & (\PDO::FETCH_CLASS | \PDO::FETCH_UNIQUE)) === (\PDO::FETCH_CLASS | \PDO::FETCH_UNIQUE)){
	$out = []
	foreach ($this->data AS $row){
		$o = new obj
		foreach ((array)$row AS $k => $v) $o->$k = $v
		$out[$row['id'] ?? count($out)] = $o
	}
	return $out
}
if (($mode & \PDO::FETCH_CLASS) === \PDO::FETCH_CLASS){
	$out = []
	foreach ($this->data AS $row){
		$o = new obj
		foreach ((array)$row AS $k => $v) $o->$k = $v
		$out[] = $o
	}
	return $out
}
return $this->data
method

%JSON_result -> fetchObject ($class = 'obj')

line 44
if (!$this->data) return null
$row = reset($this->data)
$o = new $class
foreach ((array)$row AS $k => $v) $o->$k = $v
return $o
method

%JSON_result -> fetch ($mode = 2)

line 52
if (!$this->data) return null
$row = reset($this->data)
if ($mode === \PDO::FETCH_COLUMN) return reset((array)$row)
return $row
method

%JSON_result -> fetchColumn ($col = 0)

line 59
if (!$this->data) return false
$row = reset($this->data)
$vals = array_values((array)$row)
return $vals[$col] ?? false
method

%JSON_result -> rowCount

line 66
count($this->data)
object

%migrate

/phlo/resources/DB/migrate.phlo
version 1.0
creator q-ai.nl
summary Database migration runner
package database
frontend false
backend true
requires @DB
tags database migrate migrations sql schema
static

migrate :: setup ($db = null)

line 10
$db ??= %MySQL
$db->query("CREATE TABLE IF NOT EXISTS `migrations` (`id` int unsigned NOT NULL AUTO_INCREMENT, `name` varchar(255) NOT NULL, `batch` int unsigned NOT NULL, `applied_at` int unsigned NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY (`name`))")
static

migrate :: applied ($db = null)

line 15
$db ??= %MySQL
return $db->column(table: 'migrations', columns: 'name', order: 'id ASC')
static

migrate :: run (string $path, $db = null)

line 20
$db ??= %MySQL
static::setup($db)
$applied = static::applied($db)
$files = glob($path.'/*.sql')
sort($files)
$batch = $applied ? ($db->item(table: 'migrations', columns: 'MAX(batch)') + 1) : 1
$count = 0
foreach ($files AS $file){
	$name = basename($file, '.sql')
	if (in_array($name, $applied)) continue
	$sql = file_get_contents($file)
	foreach (array_filter(array_map('trim', explode(';', $sql))) AS $statement) $db->query($statement)
	$db->create('migrations', name: $name, batch: $batch, applied_at: time())
	$count++
}
return $count
static

migrate :: rollback ($db = null)

line 39
$db ??= %MySQL
$batch = $db->item(table: 'migrations', columns: 'MAX(batch)')
if (!$batch) return 0
$names = $db->query('SELECT name FROM migrations WHERE batch=?', $batch)->fetchAll(\PDO::FETCH_COLUMN)
$db->delete('migrations', 'batch=?', $batch)
return count($names)
object

%model

/phlo/resources/DB/model.phlo
version 1.1
creator q-ai.nl
summary Phlo ORM class with unified columns and schema
type abstract class
package database
frontend false
backend true
requires @DB @MySQL apcu? audit?
tags orm model database records schema
static

model :: DB

line 11
%app->DB ?? %MySQL
static

model :: objRecords

line 12
[]
static

model :: objLoaded

line 13
[]
static

model :: objCache

line 14
false
static

model :: objRecordLimit

line 15
10000
static

model :: objSoftDelete

line 16
false
static

model :: objAudit

line 17
false
static

model :: objValidate

line 18
false
static

model :: objLastErrors

line 19
[]
static

model :: idColumn

line 20
'id'
static

model :: idType

line 21
'int'
static

model :: canView

line 23
true
static

model :: canCreate

line 24
true
static

model :: canChange

line 25
true
static

model :: canDelete

line 26
true
static

model :: state

line 28
%req->model ??= obj(meta: [], records: [], loaded: [], includeDeleted: [])
static

model :: columns

line 29
if (isset(static::$columns)) return static::$columns
if (!method_exists(static::class, 'schema')) return static::$table.'.*'
$state = static::state()
$key = spl_object_id(static::DB()).':'.static::DB()->fieldQuotes.':'.static::$table
return $state->meta[static::class]['columns'][$key] ??= static::_columns()
static

model :: _columns

line 36
$fq = static::DB()->fieldQuotes
$list = array_merge(...array_values(array_filter(loop(static::fields(), fn($field) => loop($field->objColumns, fn($col) => static::$table."$fq.$fq".$col)))))
return $fq.implode("$fq,$fq", $list).$fq
static

model :: fields

line 41
if (!method_exists(static::class, 'schema')) return static::$fields ?? []
$state = static::state()
return $state->meta[static::class]['fields'] ??= static::_fields()
static

model :: _fields

line 46
$reserved = ['table','order','fields','columns','create','change','delete','records','record','column','item','pair','DB','objRecords','objLoaded','objCache','objState','objSave','objGet','objAudit','objValidate','objLastErrors','idColumn','idType']
$fields = loop(static::schema(), fn($field, $column) => last($field->name ??= $column, $field->type === 'parent' && $field->obj ??= $column, $field))
foreach ($reserved AS $word) isset($fields[$word]) && error("Reserved column name '$word' in ".static::class)
return $fields
static

model :: field ($name)

line 52
static::fields()[$name]
static

model :: create (...$args)

line 54
$class = static::class
if (static::objValidate() && !static::objRunValidation($args)) return null
$record = new $class(...$args)
method_exists(static::class, 'beforeSave') && $record->beforeSave()
method_exists(static::class, 'beforeCreate') && $record->beforeCreate()
$id = static::createRecord(...$args)
$record = static::record(id: $id)
method_exists(static::class, 'afterCreate') && $record->afterCreate()
method_exists(static::class, 'afterSave') && $record->afterSave()
static::objAudit() && %audit->log($record, 'create', [], (array)$record)
return $record
static

model :: objRunValidation ($data)

line 68
$errors = []
$fields = static::fields()
foreach ($data AS $column => $value){
	if (!($field = $fields[$column] ?? null) || !method_exists($field, 'objValidate')) continue
	if ($error = $field->objValidate($value)) $errors[$column] = $error
}
static::$objLastErrors = $errors
return empty($errors)
static

model :: objErrors

line 79
static::$objLastErrors ?? []
static

model :: createRecord (...$args)

line 80
static::DB()->create(static::$table, ...$args)
static

model :: change ($where, ...$args)

line 81
static::DB()->change(static::$table, $where, ...$args)
static

model :: delete ($where, ...$args)

line 83
if (method_exists(static::class, 'beforeDelete') || method_exists(static::class, 'afterDelete') || static::objAudit()){
	$records = static::DB()->query('SELECT '.static::$table.'.* FROM '.static::$table.' WHERE '.$where, ...$args)->fetchAll(\PDO::FETCH_CLASS, static::class)
	foreach ($records AS $record) method_exists(static::class, 'beforeDelete') && $record->beforeDelete()
	if (static::objSoftDelete()) $result = static::DB()->change(static::$table, $where, ...array_values($args), deleted_at: time())
	else $result = static::DB()->delete(static::$table, $where, ...$args)
	foreach ($records AS $record){
		method_exists(static::class, 'afterDelete') && $record->afterDelete()
		static::objAudit() && %audit->log($record, 'delete', (array)$record, [])
	}
	return $result
}
if (static::objSoftDelete()) return static::DB()->change(static::$table, $where, ...array_values($args), deleted_at: time())
return static::DB()->delete(static::$table, $where, ...$args)
static

model :: objLogChange ($where, ...$args)

line 99
if (!static::objAudit()) return static::change($where, ...$args)
$old = static::records(where: $where)
$result = static::DB()->change(static::$table, $where, ...$args)
foreach ($old AS $record){
	$fresh = static::record(id: $record->id)
	$fresh && %audit->log($fresh, 'update', (array)$record, (array)$fresh)
}
return $result
method

%model -> objSave

line 110
$pk = static::idColumn()
$pkValue = $this->$pk ?? $this->id ?? null
$pkValue || error('Can\'t save '.static::class.' record without '.$pk)
$isNew = !static::item([$pk => $pkValue, 'columns' => $pk])
$old = $isNew ? null : clone $this
method_exists(static::class, 'beforeSave') && $this->beforeSave($old)
if ($isNew){
	method_exists(static::class, 'beforeCreate') && $this->beforeCreate()
	static::createRecord(...$this)
	$saved = static::record([$pk => $pkValue])
	method_exists(static::class, 'afterCreate') && $saved->afterCreate()
}
else {
	method_exists(static::class, 'beforeChange') && $this->beforeChange($old)
	static::change($pk.'=?', $pkValue, ...$this)
	$saved = static::record([$pk => $pkValue])
	method_exists(static::class, 'afterChange') && $saved->afterChange($old)
	static::objAudit() && %audit->log($saved, 'update', (array)$old, (array)$saved)
}
method_exists(static::class, 'afterSave') && $saved->afterSave($old)
return $saved
static

model :: transaction ($callback)

line 134
static::DB()->transaction($callback)
static

model :: query

line 135
phlo('query', class: static::class)
static

model :: objIncludeDeleted

line 137
static::state()->includeDeleted[static::class] ?? false
static

model :: objWithDeleted ($callback)

line 138
$state = static::state()
$state->includeDeleted[static::class] = true
try {
	$result = $callback()
} finally {
	unset($state->includeDeleted[static::class])
}
return $result
static

model :: objRestore ($id)

line 148
static::DB()->change(static::$table, static::idColumn().'=?', $id, deleted_at: null)
static

model :: column (...$args)

line 150
static::recordsLoad($args, 'fetchAll', [\PDO::FETCH_COLUMN])
static

model :: item (...$args)

line 151
static::recordsLoad($args, 'fetch', [\PDO::FETCH_COLUMN])
static

model :: pair (...$args)

line 152
static::recordsLoad($args, 'fetchAll', [\PDO::FETCH_KEY_PAIR])
static

model :: records (...$args)

line 153
static::recordsLoad($args, 'fetchAll', [\PDO::FETCH_CLASS|\PDO::FETCH_UNIQUE, static::class], true)
static

model :: recordCount (...$args)

line 154
static::item(...$args, columns: 'COUNT('.static::idColumn().')')
static

model :: record (...$args)

line 155
count($records = static::records(...$args)) > 1 ? error('Multiple records for '.static::class) : (current($records) ?: null)
static

model :: recordsLoad ($args, $fetch, $fetchMode, $saveRelations = false)

line 157
$pk = static::idColumn()
$args['table'] ??= static::$table
$saveRelations && $args['columns'] ??= static::$table.'.'.$pk.' as _,'.static::columns()
isset(static::$joins) && debug && error('DEPRECATED: static $joins in '.static::class.'. Use getParent/getChildren/getMany instead.')
isset(static::$joins) && $args['joins'] = static::$joins.(isset($args['joins']) ? " $args[joins]" : void)
method_exists(static::class, 'where') && $args['where'] = static::where().(isset($args['where']) ? " AND $args[where]" : void)
static::objSoftDelete() && !static::objIncludeDeleted() && $args['where'] = 'deleted_at IS NULL'.(isset($args['where']) ? " AND $args[where]" : void)
isset(static::$group) && $args['group'] ??= static::$group
isset(static::$order) && $args['order'] ??= static::$order
if ($cacheKey = $args['cacheKey'] ?? null) unset($args['cacheKey'])
if ($duration = $args['cache'] ?? static::objCache()){
	unset($args['cache'])
	$cacheArgs = $args; ksort($cacheArgs)
	$records = apcu($cacheKey ?? static::class.slash.md5(json_encode($cacheArgs)), fn() => static::DB()->load(...$args)->$fetch(...$fetchMode), $duration === true ? 86400 : $duration)
}
else $records = static::DB()->load(...$args)->$fetch(...$fetchMode)
if ($saveRelations && $records){
	$state = static::state()
	$state->records[static::class] = ($state->records[static::class] ?? []) + array_column($records, null, $pk)
	count($state->records[static::class]) > static::objRecordLimit() && $state->records[static::class] = array_slice($state->records[static::class], -static::objRecordLimit(), preserve_keys: true)
}
return $records
static

model :: objRel ($key)

line 182
$state = static::state()
return $state->meta[static::class][$key] ??= method_exists(static::class, $key) ? static::$key() : static::$$key ?? []
prop

%model -> objState

line 187
['parents' => [], 'children' => [], 'many' => []]
method

%model -> objGet ($key)

line 188
$this->getParent($key) ?? $this->getChildren($key) ?? $this->getMany($key)
method

%model -> objIn ($ids)

line 189
$ids ? dq.implode(dq.comma.dq, $ids).dq : 'NULL'
method

%model -> getParent ($key)

line 191
if (array_key_exists($key, $this->objState['parents'])) return $this->objState['parents'][$key]
$state = static::state()
$parents = self::objRel('objParents')
if (!$relation = $parents[$key] ?? null) return
$isArray = is_array($relation)
$class = $isArray ? $relation['obj'] : $relation
$column = $isArray ? $relation['key'] ?? $key : $key
if (!$parentId = $this->objData[$column] ?? null) return $this->objState['parents'][$key] = null
if (!isset($state->records[$class][$parentId])){
	$idsToLoad = [$parentId => true]
	$allObjData = array_map(fn($record) => $record->objData, $state->records[static::class] ?? [])
	foreach ($parents as $pKey => $pRelation){
		$pIsArray = is_array($pRelation)
		$pClass = $pIsArray ? $pRelation['obj'] : $pRelation
		if ($pClass === $class) foreach (array_column($allObjData, $pIsArray ? $pRelation['key'] ?? $pKey : $pKey) as $pId) $pId && !isset($state->records[$class][$pId]) && $idsToLoad[$pId] = true
	}
	if ($idsToLoad = array_keys($idsToLoad)) $class::records(where: $class::idColumn().' IN ('.$this->objIn($idsToLoad).')')
}
$parentObject = $state->records[$class][$parentId] ?? null
return $this->objState['parents'][$key] = $parentObject
method

%model -> getChildren ($key)

line 214
if (array_key_exists($key, $this->objState['children'])) return $this->objState['children'][$key]
$state = static::state()
if (!$relation = self::objRel('objChildren')[$key] ?? null) return
$isArray = is_array($relation)
$class = $isArray ? $relation['obj'] : $relation
$column = $isArray ? $relation['key'] : static::objShortName()
if (!isset($state->loaded[static::class]['children'][$key])){
	$parentIds = array_keys($state->records[static::class] ?? [])
	if ($parentIds){
		$fq = static::DB()->fieldQuotes
		$children = $class::records(where: $fq.$column.$fq.' IN ('.$this->objIn($parentIds).')')
		foreach ($state->records[static::class] AS $parentRecord) $parentRecord->objState['children'][$key] = []
		foreach ($children AS $childId => $child) !is_null($pId = $child->objData[$column] ?? null) && isset($state->records[static::class][$pId]) && $state->records[static::class][$pId]->objState['children'][$key][$childId] = $child
	}
	$state->loaded[static::class]['children'][$key] = true
}
return $this->objState['children'][$key] ?? []
method

%model -> getMany ($key)

line 234
if (array_key_exists($key, $this->objState['many'])) return $this->objState['many'][$key]
$state = static::state()
if (!$relation = self::objRel('objMany')[$key] ?? null) return
$class = $relation['obj']
if (!isset($state->loaded[static::class]['many'][$key])){
	$parentIds = array_keys($state->records[static::class] ?? [])
	if ($parentIds){
		$fq = static::DB()->fieldQuotes
		$lk = $relation['localKey']; $fk = $relation['foreignKey']
		$pivotRows = static::DB()->rows(table: $relation['table'], columns: $fq.$lk.$fq.comma.$fq.$fk.$fq, where: $fq.$lk.$fq.' IN ('.$this->objIn($parentIds).')')
		$targetIds = array_unique(array_map(fn($row) => $row->{$relation['foreignKey']}, $pivotRows ?: []))
		$targetRecords = $targetIds ? $class::records(where: $class::idColumn().' IN ('.$this->objIn($targetIds).')') : []
		foreach ($state->records[static::class] AS $parentRecord) $parentRecord->objState['many'][$key] = []
		foreach ($pivotRows ?: [] AS $row){
			$parentId = $row->$lk
			$foreignId = $row->$fk
			if (isset($state->records[static::class][$parentId]) && isset($targetRecords[$foreignId])) $state->records[static::class][$parentId]->objState['many'][$key][$foreignId] = $targetRecords[$foreignId]
		}
	}
	$state->loaded[static::class]['many'][$key] = true
}
return $this->objState['many'][$key] ?? []
method

%model -> getCount ($key)

line 259
if (array_key_exists($key, $this->objState['counts'] ?? [])) return $this->objState['counts'][$key]
$state = static::state()
if ($relation = self::objRel('objChildren')[$key] ?? null){
	if (!isset($state->loaded[static::class]['children_count'][$key])){
			$parentIds = array_keys($state->records[static::class] ?? [])
			if ($parentIds){
				$isArray = is_array($relation)
				$class = $isArray ? $relation['obj'] : $relation
				$column = $isArray ? $relation['key'] : static::objShortName()
				$fq = static::DB()->fieldQuotes
				$counts = $class::pair(columns: $fq.$column.$fq.', COUNT(*)', where: $fq.$column.$fq.' IN ('.$this->objIn($parentIds).')', group: $fq.$column.$fq)
				foreach ($state->records[static::class] as $id => $record) $record->objState['counts'][$key] = (int)($counts[$id] ?? 0)
			}
			$state->loaded[static::class]['children_count'][$key] = true
	}
	return $this->objState['counts'][$key] ?? 0
}
if ($relation = self::objRel('objMany')[$key] ?? null){
	if (!isset($state->loaded[static::class]['many_count'][$key])){
		$parentIds = array_keys($state->records[static::class] ?? [])
		if ($parentIds){
			$fq = static::DB()->fieldQuotes
			$localKey = $relation['localKey']
			$counts = static::DB()->load(table: $relation['table'], columns: $fq.$localKey.$fq.',COUNT(*)', where: $fq.$localKey.$fq.' IN ('.$this->objIn($parentIds).')', group: $fq.$localKey.$fq)->fetchAll(\PDO::FETCH_KEY_PAIR)
			foreach ($state->records[static::class] as $id => $record) $record->objState['counts'][$key] = (int)($counts[$id] ?? 0)
		}
		$state->loaded[static::class]['many_count'][$key] = true
	}
	return $this->objState['counts'][$key] ?? 0
}
return 0
method

%model -> getLast ($key)

line 293
if (array_key_exists($key, $this->objState['last_child'] ?? [])) return $this->objState['last_child'][$key]
$state = static::state()
if ($relation = self::objRel('objChildren')[$key] ?? null){
	if (!isset($state->loaded[static::class]['last_child'][$key])){
		if ($parentIds = array_keys($state->records[static::class] ?? [])){
			$isArray = is_array($relation)
			$class = $isArray ? $relation['obj'] : $relation
			$column = $isArray ? $relation['key'] : static::objShortName()
			$childTable = $class::$table
			$fq = static::DB()->fieldQuotes
			$qt = $fq.$childTable.$fq
			$qc = $fq.$column.$fq
			$ids = $this->objIn($parentIds)
			$childPk = $class::idColumn()
			$joins = ' INNER JOIN (SELECT MAX('.$fq.$childPk.$fq.') AS last_id, '.$qc.' AS parent_id FROM '.$qt.' WHERE '.$qc.' IN ('.$ids.') GROUP BY '.$qc.') AS lcmax ON '.$qt.'.'.$fq.$childPk.$fq.' = lcmax.last_id'
			$lastChildren = $class::records(joins: $joins)
			foreach ($state->records[static::class] as $record) $record->objState['last_child'][$key] = null
			foreach ($lastChildren as $child) if (isset($state->records[static::class][$parentId = $child->objData[$column]])) $state->records[static::class][$parentId]->objState['last_child'][$key] = $child
		}
		$state->loaded[static::class]['last_child'][$key] = true
	}
	return $this->objState['last_child'][$key] ?? null
}
return null
static

model :: objResolveClass ($name)

line 320
$name
static

model :: objShortName ($class = null)

line 321
$class ?? static::class
static

model :: objParents

line 323
if (property_exists(static::class, 'objParents')) return static::$objParents
if (!method_exists(static::class, 'schema')) return []
return loop(array_filter(static::fields(), fn($f) => $f->type === 'parent'), fn($f, $c) => $f->key ? arr(obj: static::objResolveClass($f->obj), key: $f->key) : (static::objResolveClass($f->obj ?? $c)))
static

model :: objChildren

line 329
if (property_exists(static::class, 'objChildren')) return static::$objChildren
if (!method_exists(static::class, 'schema')) return []
return loop(array_filter(static::fields(), fn($f) => $f->type === 'child'), fn($f, $c) => $f->key ? arr(obj: static::objResolveClass($f->obj), key: $f->key) : (static::objResolveClass($f->obj ?? $c)))
static

model :: objMany

line 335
if (property_exists(static::class, 'objMany')) return static::$objMany
if (!method_exists(static::class, 'schema')) return []
return loop(array_filter(static::fields(), fn($f) => $f->type === 'many'), fn($f) => arr(obj: static::objResolveClass($f->obj), table: $f->table, localKey: $f->localKey ?? static::objShortName(), foreignKey: $f->foreignKey ?? $f->obj))
object

%MySQL

/phlo/resources/DB/MySQL.phlo
version 1.1
creator q-ai.nl
summary MySQL handler via DB class
extends DB
package database
frontend false
backend true
requires @DB creds:mysql php-ext:pdo php-ext:pdo_mysql
tags mysql pdo database sql
prop

%MySQL -> PDO

line 11
new \PDO('mysql:host='.%creds->mysql->host.';dbname='.%creds->mysql->database, %creds->mysql->user, %creds->mysql->password)
prop

%MySQL -> objPers

line 12
true
object

%PostgreSQL

/phlo/resources/DB/PostgreSQL.phlo
version 1.0
creator q-ai.nl
summary PostgreSQL resource
extends DB
package database
frontend false
backend true
requires @DB creds:postgresql php-ext:pdo php-ext:pdo_pgsql
tags postgresql pdo database sql
prop

%PostgreSQL -> PDO

line 11
new PDO('pgsql:host='.%creds->postgresql->host.';dbname='.%creds->postgresql->database, %creds->postgresql->user, %creds->postgresql->password)
prop

%PostgreSQL -> fieldQuotes

line 12
dq
object

%Qdrant

/phlo/resources/DB/Qdrant.phlo
version 1.0
creator q-ai.nl
summary Embeddings resource with Qdrant
package ai
frontend false
backend true
requires @AI creds:qdrant apcu
tags qdrant embeddings vector search ai
method

%Qdrant -> get (string $input, ?string $model = null)

line 10
apcu('embedding/'.token(input: $input), fn($input) => %AI->embedding(input: $input, model: $model), 86400 * 28)
method

%Qdrant -> collections

line 12
array_column($this->request('collections')->result->collections, 'name')
method

%Qdrant -> create ($collection, $size = 1536, $distance = 'Cosine')

line 13
$this->request("collections/$collection", PUT: arr(vectors: arr(size: $size, distance: $distance)))->status === 'ok'
method

%Qdrant -> upsert ($collection, $id, $input, ...$payload)

line 14
$this->request("collections/$collection/points", PUT: arr(points: [arr(id: $id, vector: $this->get($input), payload: $payload ?: null)]))->result->operation_id
method

%Qdrant -> delete ($collection, ...$ids)

line 15
$this->request("collections/$collection/points/delete", POST: arr(points: $ids))->result
method

%Qdrant -> search ($collection, $input = null, $top = 100)

line 16
create($this->request("collections/$collection/points/search", POST: arr(vector: is_null($input) ? array_fill(0, 1536, 0) : $this->get($input), top: $top, with_payload: true))->result, fn($record) => $record->id, fn($record) => last($record = array_merge(get_object_vars($record), get_object_vars($record->payload)), obj(...array_filter($record, fn($key) => $key !== 'payload', ARRAY_FILTER_USE_KEY))))
method

%Qdrant -> drop ($collection)

line 17
$this->request("collections/$collection", DELETE: true)->result
method

%Qdrant -> request ($uri, ...$data)

line 19
json_decode(HTTP(%creds->qdrant->server.$uri, %creds->qdrant->key ? ['api-key: '.%creds->qdrant->key] : [], true, ...$data))
object

%query

/phlo/resources/DB/query.phlo
version 1.0
creator q-ai.nl
summary Fluent query builder for Phlo ORM
package database
frontend false
backend true
requires @DB
tags query builder orm database sql
prop

%query -> class

line 10
prop

%query -> conditions

line 11
[]
prop

%query -> bindings

line 12
[]
prop

%query -> orderBy

line 13
prop

%query -> limitVal

line 14
prop

%query -> offsetVal

line 15
method

%query -> fq

line 16
($class = $this->class) ? $class::DB()->fieldQuotes : bt
method

%query -> q ($column)

line 17
preg_match('/^[A-Za-z_][A-Za-z0-9_.]*$/', $column) || error("Invalid column for query builder: $column")
$fq = $this->fq
return implode(dot, array_map(fn($part) => $fq.$part.$fq, explode(dot, $column)))
method

%query -> eq ($column, $value)

line 23
$this->where($this->q($column)." = ?", $value)
method

%query -> neq ($column, $value)

line 24
$this->where($this->q($column)." != ?", $value)
method

%query -> gt ($column, $value)

line 25
$this->where($this->q($column)." > ?", $value)
method

%query -> gte ($column, $value)

line 26
$this->where($this->q($column)." >= ?", $value)
method

%query -> lt ($column, $value)

line 27
$this->where($this->q($column)." < ?", $value)
method

%query -> lte ($column, $value)

line 28
$this->where($this->q($column)." <= ?", $value)
method

%query -> like ($column, $value)

line 29
$this->where($this->q($column)." LIKE ?", $value)
method

%query -> in ($column, array $values)

line 30
$this->where($this->q($column)." IN (".implode(',', array_fill(0, count($values), '?')).")", ...$values)
method

%query -> isNull ($column)

line 31
$this->where($this->q($column)." IS NULL")
method

%query -> notNull ($column)

line 32
$this->where($this->q($column)." IS NOT NULL")
method

%query -> between ($column, $min, $max)

line 33
$this->where($this->q($column)." BETWEEN ? AND ?", $min, $max)
method

%query -> raw ($sql, ...$bindings)

line 34
$this->where($sql, ...$bindings)
method

%query -> where ($condition, ...$values)

line 35
$this->conditions[] = $condition
foreach ($values AS $v) $this->bindings[] = $v
return $this
method

%query -> order ($order)

line 41
$this->orderBy = $order
return $this
method

%query -> limit ($limit)

line 46
$this->limitVal = $limit
return $this
method

%query -> offset ($offset)

line 51
$this->offsetVal = $offset
return $this
method

%query -> build

line 55
$where = $this->conditions ? implode(' AND ', $this->conditions) : void
$limit = $this->limitVal ? ($this->offsetVal ? "$this->offsetVal,$this->limitVal" : "$this->limitVal") : void
$args = ['where' => $where ?: void, 'order' => $this->orderBy ?: void, 'limit' => $limit ?: void]
foreach ($this->bindings AS $b) $args[] = $b
return $args
prop

%query -> records

line 63
($class = $this->class) && $class::records(...$this->build)
prop

%query -> record

line 64
($class = $this->class) && $class::record(...$this->build)
prop

%query -> column

line 65
($class = $this->class) && $class::column(...$this->build)
prop

%query -> item

line 66
($class = $this->class) && $class::item(...$this->build)
prop

%query -> count

line 67
($class = $this->class) && $class::recordCount(...$this->build)
method

%query -> delete

line 68
$class = $this->class
$where = $this->conditions ? implode(' AND ', $this->conditions) : error('Cannot delete without conditions')
return $class::delete($where, ...$this->bindings)
object

%SQLite

/phlo/resources/DB/SQLite.phlo
version 1.0
creator q-ai.nl
summary SQLite resource
extends DB
package database
frontend false
backend true
requires @DB php-ext:pdo php-ext:pdo_sqlite
tags sqlite pdo database sql
static

SQLite :: __handle

line 11
"SQLite/$file"
method

%SQLite -> __construct (private string $file)

line 12
prop

%SQLite -> PDO

line 13
new PDO('sqlite:'.$this->file)

DOM

object

%cookiewall

/phlo/resources/DOM/cookiewall.phlo
version 1.2
creator q-ai.nl
summary Subtle GDPR cookie-consent banner (English-only). For multilingual sites: DOM/cookiewall.translated.
package privacy
frontend true
backend true
requires @cookies
tags gdpr consent cookies privacy
static

cookiewall :: __handle

line 10
null
prop

%cookiewall -> choice

line 12
%cookies->cookieChoice ?? null
method

%cookiewall -> hasChosen

line 13
$this->choice !== null
method

%cookiewall -> canTrack

line 14
$this->choice === 'all'
method

%cookiewall -> canAnalytics

line 15
$this->choice === 'all'
route

route async POST cookiewall accept all

line 17
%cookies->objSet('cookieChoice', 'all', ['expires' => time() + 60 * 60 * 24 * 365, 'httponly' => false])
apply(remove: '#cookiewall')
route

route async POST cookiewall accept essential

line 22
%cookies->objSet('cookieChoice', 'essential', ['expires' => time() + 60 * 60 * 24 * 365, 'httponly' => false])
apply(remove: '#cookiewall')
view

%cookiewall -> banner

line 27
<if !$this->hasChosen()>
	<div#cookiewall role=region aria-label="Cookie choice">
		<p>We use essential cookies to make this site work. With your permission we also use analytics to improve the site.</p>
		<div.actions>
			<form.async method=post action=/cookiewall/accept/essential>
				<button.ghost type=submit>Essential only</button>
			</form>
			<form.async method=post action=/cookiewall/accept/all>
				<button.primary type=submit>Accept</button>
			</form>
		</div>
	</div>
</if>
view

style

line 42
#cookiewall {
	background: #1a1a1a
	border-radius: 8px
	bottom: 16px
	box-shadow: 0 8px 32px #0004
	color: #fff
	font-size: 13px
	left: 16px
	line-height: 1.5
	max-width: 360px
	padding: 14px 16px
	position: fixed
	right: 16px
	z-index: 9999
	@media(min-width: 600px): right: auto
	p: margin: 0 0 10px
	.actions {
		display: flex
		gap: 8px
		justify-content: flex-end
	}
	button {
		border-radius: 4px
		border: 0
		cursor: pointer
		font-size: 12px
		padding: 6px 12px
	}
	button.ghost {
		background: #444
		color: #fff
	}
	button.primary {
		background: #fff
		color: #1a1a1a
		font-weight: 600
	}
}
object

%cookiewall_translated

/phlo/resources/DOM/cookiewall.translated.phlo
extends cookiewall
class cookiewall_translated
version 1.1
creator q-ai.nl
summary Multilingual cookiewall variant. {en: ...} tags resolve via Phlo's lang system. For English-only: DOM/cookiewall.
package privacy
frontend true
backend true
requires @cookies
tags gdpr consent cookies privacy multilingual
view

%cookiewall_translated -> banner

line 12
<if !$this->hasChosen()>
	<div#cookiewall role=region aria-label="{en: Cookie choice}">
		<p>{en: We use essential cookies to make this site work. With your permission we also use analytics to improve the site.}</p>
		<div.actions>
			<form.async method=post action=/cookiewall/accept/essential>
				<button.ghost type=submit>{en: Essential only}</button>
			</form>
			<form.async method=post action=/cookiewall/accept/all>
				<button.primary type=submit>{en: Accept}</button>
			</form>
		</div>
	</div>
</if>
object

%CSS_fixes

/phlo/resources/DOM/CSS.fixes.phlo
version 1.0
creator q-ai.nl
summary Single Page App basic CSS boilerplate fixes
package css
frontend true
backend false
tags css fixes boilerplate reset
view

style

line 9
*, ::before, ::after: box-sizing: border-box
a, area, button, input, label, select, summary, textarea, [tabindex]: touch-action: manipulation
button:focus, input:focus, select:focus, textarea:focus, [contenteditable]:focus: outline: 0
input::-webkit-outer-spin-button, input::-webkit-inner-spin-button: -webkit-appearance: none
input[type="number"]: -moz-appearance: textfield
table: border-collapse: collapse
::-ms-expand: display: none
object

%CSS_var

/phlo/resources/DOM/CSS.var.phlo
version 1.0
creator q-ai.nl
summary CSS variable proxy via app.var
package css
frontend true
backend false
tags css variables app.var frontend
view

script

line 9
Object.defineProperty(app, 'var', {get(){return new Proxy({}, {get(_, key){return getComputedStyle(document.documentElement).getPropertyValue(`--${key}`).trim()}, set(_, key, value){ return document.documentElement.style.setProperty(`--${key}`, value)}})}, configurable: true})
app.mod.setvar = (key, value) => app.var[key] = value
object

%datatags

/phlo/resources/DOM/datatags.phlo
version 1.0
creator q-ai.nl
summary Single Page App datatag plugin
package dom
frontend true
backend false
requires @DOM
tags dom datatag dataset spa events
view

script

line 10
on('click', '[data-get], [data-post], [data-put], [data-patch], [data-delete]', (el, e) => {
	if (el.dataset.confirm) return
	e.preventDefault()
	let method, path, data = null
	if ((path = el.dataset.get) !== undefined) method = 'get'
	else if ((path = el.dataset.delete) !== undefined) method = 'delete'
	else [data = {}, Object.keys(el.dataset).forEach((key) => key === 'post' || key === 'put' || key === 'patch' ? [method = key, path = el.dataset[key]] : data[key] = el.dataset[key])]
	app[method](path, data)
})
object

%dialog

/phlo/resources/DOM/dialog.phlo
version 1.0
creator q-ai.nl
summary Single Page App dialog resource
package dom
frontend true
backend false
requires @DOM
tags dom dialog modal confirm prompt alert
view

script

line 10
window.alert = app.mod.alert = msg => phlo.dialog('alert', msg)
window.confirm = msg => phlo.dialog('confirm', msg)
window.prompt = (msg, defaultValue) => phlo.dialog('prompt', msg, defaultValue)

phlo.dialog = async (type, message, defaultValue = '') => new Promise((resolve) => {
	app.mod.append('body', '<dialog id="phloDialog" class="phlo-dialog" role="dialog" aria-modal="true">\n<form method="dialog">\n<p class="phlo-dialog__message"></p>\n' + (type === 'prompt' ? '<input class="phlo-dialog__input" name="value">' : '') + '\n<menu class="phlo-dialog__actions">\n<button value="1" autofocus>OK</button>\n' + (type !== 'alert' ? '<button value="0">Cancel</button>' : '') + '\n</menu>\n</form>\n</dialog>')
	const dialog = obj('#phloDialog')
	const messageEl = dialog.querySelector('.phlo-dialog__message')
	messageEl && (messageEl.textContent = String(message ?? ''))
	if (type === 'prompt') dialog.querySelector('input').value = String(defaultValue ?? '')
	dialog.showModal()
	dialog.addEventListener('close', () => {
		const value = dialog.returnValue
		const input = dialog.querySelector('input')
		dialog.remove()
		if (type === 'alert') return resolve()
		if (type === 'confirm') return resolve(value === '1')
		if (type === 'prompt') return resolve(value === '1' ? input.value : null)
	})
})

on('click', '[data-confirm]', async (el, e) => {
	e.preventDefault()
	if (!await window.confirm(el.dataset.confirm)) return
	delete el.dataset.confirm
	app.update()
	el.click()
})
object

%exists

/phlo/resources/DOM/exists.phlo
version 1.0
creator q-ai.nl
summary onExist helper for dynamic SPA elements
package dom
frontend true
backend false
requires @DOM
tags dom onexist spa lifecycle
view

script

line 10
phlo.exist = []
phlo.existing = new WeakMap

const onExist = (els, cb) => phlo.exist.push({els, cb})

app.updates.push(() => {
	const existing = []
	phlo.exist.forEach(item => objects(item.els).forEach(el => phlo.existing.has(el) || existing.push({el, cb: item.cb})))
	existing.forEach(item => [phlo.existing.has(item.el) || phlo.existing.set(item.el, 'exist'), item.cb(item.el)])
})
object

%form

/phlo/resources/DOM/form.phlo
version 1.0
creator q-ai.nl
summary Single Page App form handler and input state saver
package dom
frontend true
backend false
requires @DOM
tags dom form input state spa
view

script

line 10
on('input change', 'input, select, textarea', input => {
	if (input.tagName === 'SELECT') input.querySelectorAll('option').forEach((option, index) => option.selected ? option.setAttribute('selected', '') : option.removeAttribute('selected'))
	if (input.type === 'checkbox') input.checked ? input.setAttribute('checked', '') : input.removeAttribute('checked')
	if (input.type === 'text' && input.value !== input.getAttribute('value')) input.setAttribute('value', input.value)
	if (input.type === 'textarea' && input.value !== input.innerHTML) input.innerHTML = input.value
	phlo.state.replace()
	return false
})
on('submit', 'form.async', (form, e) => [e.preventDefault(), app[(form.attributes.method?.value ?? 'GET').toLowerCase()](new URL(form.action).pathname.substr(1), new FormData(form))])
object

%image_resizer

/phlo/resources/DOM/image.resizer.phlo
version 1.0
creator q-ai.nl
summary Client-side file upload image resizer
package dom
frontend true
backend false
tags dom image resize upload canvas
view

script

line 9
const imageResizer = (file, maxWidth, maxHeight, cb, quality = .8) => {
	const img = new Image
	img.onload = () => {
		let width = img.width, height = img.height
		const aspectRatio = width / height
		if (width > maxWidth || height > maxHeight){
			if (width > height){
				width = maxWidth
				height = Math.round(maxWidth / aspectRatio)
			}
			else {
				height = maxHeight
				width = Math.round(maxHeight * aspectRatio)
			}
		}
		const canvas = document.createElement('canvas')
		canvas.width = width
		canvas.height = height
		canvas.getContext('2d').drawImage(img, 0, 0, width, height)
		cb(canvas.toDataURL(file.type, quality))
	}
	img.src = URL.createObjectURL(file)
}
object
/phlo/resources/DOM/link.phlo
version 1.0
creator q-ai.nl
summary Single Page App async link handler
package dom
frontend true
backend false
requires @DOM
tags dom link async navigation spa
view

script

line 10
on('click', 'a', (a, e) => {
	if (e.ctrlKey || e.shiftKey || e.metaKey || a.dataset.confirm) return false
	const isAsync = a.classList.contains('async')
	const [uri, hash] = a.getAttribute('href').split('#')
	if (isAsync || hash) e.preventDefault()
	phlo.anchor = hash ? `#${hash}` : ''
	if (hash && (!uri || uri === location.pathname + location.search)) location.hash = phlo.anchor
	else if (isAsync) app.get(uri.substr(1))
})
object

%markdown

/phlo/resources/DOM/markdown.phlo
version 1.0
creator q-ai.nl
summary Client-side markdown parser
package dom
frontend true
backend false
tags dom markdown parser frontend
view

script

line 9
function parse_markdown(md, opts = {}){
  const o = {
    gfm: opts.gfm !== false,
    breaks: !!opts.breaks,
    headerIds: opts.headerIds !== false,
    headerPrefix: opts.headerPrefix || '',
    smartypants: !!opts.smartypants
  }
  const unnull = x => (x == null ? '' : String(x))
  let src = unnull(md).replace(/\r\n?/g, "\n")
  const escHtml = s => s.replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]))
  const trimEndNL = s => s.replace(/\s+$/,'')
  const isBlank = s => /^\s*$/.test(s)
  const slugmap = new Map()
  const slug = (t) => {
    let s = t.toLowerCase().replace(/<\/?[^>]+>/g, '').replace(/[^\p{L}\p{N}\- _]+/gu, '').trim().replace(/[\s_]+/g, '-')
    const base = o.headerPrefix + s
    let k = base, i = 1
    while (slugmap.has(k)) k = `${base}-${++i}`
    slugmap.set(k, true)
    return k
  }
  const smart = s => {
    if (!o.smartypants) return s
    return s.replace(/---/g, "-").replace(/--/g, "–").replace(/(^|[\s"(\[])(?=')/g, "$1‘").replace(/'/g, "’").replace(/(^|[\s(\[])(?=")/g, "$1“").replace(/"/g, "”").replace(/\.{3}/g, "…")
  }
  const refs = Object.create(null)
  src = src.replace(
    /^ {0,3}\[([^\]]+)\]:\s*<?([^\s>]+)>?(?:\s+(?:"([^"]*)"|'([^']*)'|\(([^)]+)\)))?\s*$/gm,
    (_, label, url, t1, t2, t3) => {
      const key = label.trim().replace(/\s+/g, ' ').toLowerCase()
      if (!refs[key]) refs[key] = { href: url, title: t1 || t2 || t3 || '' }
      return ''
    }
  )
  const tokens = []
  const lines = src.split("\n")
  function takeWhile(start, pred){
    let end = start
    while (end < lines.length && pred(lines[end], end)) end++
    return { start, end }
  }
  function pushParagraph(buf){
    const text = buf.join("\n").trimEnd()
    if (text) tokens.push({ type: "paragraph", text })
    buf.length = 0
  }
  function parseBlock(start = 0, end = lines.length){
    const para = []
    let l = start
    while (l < end){
      const line = lines[l]
      if (isBlank(line)){ pushParagraph(para); l++; continue; }
      let m = line.match(/^ {0,3}(`{3,}|~{3,})([^\n]*)$/)
      if (m){
        pushParagraph(para)
        const fenceLen = m[1].length
        const info = (m[2] || '').trim()
        let body = []
        l++
        while (l < end){
          const s = lines[l]
          const close = s.match(new RegExp(`^ {0,3}${m[1][0]}{${fenceLen},}\\s*$`))
          if (close){ l++; break; }
          body.push(s)
          l++
        }
        tokens.push({ type: "code", lang: info.split(/\s+/)[0] || '', text: trimEndNL(body.join("\n")) })
        continue
      }
      if (/^(?: {4}|\t)/.test(line)){
        pushParagraph(para)
        const { end: j } = takeWhile(l, s => /^(?: {4}|\t)/.test(s) || isBlank(s))
        const block = lines.slice(l, j).map(s => s.replace(/^(?: {4}|\t)/, '')).join("\n")
        tokens.push({ type: "code", lang: '', text: trimEndNL(block) })
        l = j; continue
      }
      if (/^ {0,3}<(?:!--|\/?(?:html|head|body|pre|script|style|table|thead|tbody|tfoot|tr|td|th|div|p|h[1-6]|blockquote|ul|ol|li|section|article|aside|details|summary|figure|figcaption)\b)/i.test(line)){
        pushParagraph(para)
        const { end: j } = takeWhile(l, (s, idx) => !(idx > l && isBlank(lines[idx-1]) && isBlank(s)))
        const html = lines.slice(l, j).join("\n")
        tokens.push({ type: "html", text: html })
        l = j; continue
      }
      if (/^ {0,3}(?:-+\s*|-{3,}|_{3,}|\*{3,})\s*$/.test(line)){
        pushParagraph(para)
        tokens.push({ type: "hr" })
        l++; continue
      }
      m = line.match(/^ {0,3}(#{1,6})[ \t]*([^#\n]*?)[ \t#]*$/)
      if (m){
        pushParagraph(para)
        tokens.push({ type: "heading", depth: m[1].length, text: m[2].trim() })
        l++; continue
      }
      if (l + 1 < end && /^[^\s].*$/.test(line) && /^ {0,3}(=+|-+)\s*$/.test(lines[l + 1])){
        pushParagraph(para)
        const depth = lines[l + 1].trim().startsWith("=") ? 1 : 2
        tokens.push({ type: "heading", depth, text: line.trim() })
        l += 2; continue
      }
      if (/^ {0,3}>\s?/.test(line)){
        pushParagraph(para)
        const { end: j } = takeWhile(l, s => /^ {0,3}>\s?/.test(s) || isBlank(s))
        const inner = lines.slice(l, j).map(s => s.replace(/^ {0,3}>\s?/, '')).join("\n")
        const sub = parse_markdown(inner, { ...o })
        tokens.push({ type: "blockquote", html: sub })
        l = j; continue
      }
      m = line.match(/^ {0,3}((?:[*+-])|\d{1,9}[.)])\s+/)
      if (m){
        pushParagraph(para)
        const bulletRe = /^ {0,3}((?:[*+-])|\d{1,9}[.)])\s+/
        const { end: j } = takeWhile(l, (s, idx) =>
          bulletRe.test(s) ||
          (/^(?: {4}|\t)/.test(s)) ||
          (!isBlank(s) && idx > l && !/^(?: {0,3}(?:[*+-]|\d{1,9}[.)])\s+)/.test(s))
        )
        const block = lines.slice(l, j)
        const ordered = /^\d/.test(m[1])
        const items = []
        let cur = []
        for (let k = 0; k < block.length; k++){
          const ln = block[k]
          const head = ln.match(bulletRe)
          if (head){
            if (cur.length) items.push(cur), cur = []
            cur.push(ln.replace(bulletRe, ''))
          } else {
            cur.push(ln.replace(/^(?: {4}|\t)/, ''))
          }
        }
        if (cur.length) items.push(cur)
        const parsedItems = items.map(linesArr => {
          let raw = linesArr.join("\n").replace(/\n\s+$/,'')
          let checked = null
          if (o.gfm){
            const t = raw.match(/^\[([ xX])\][ \t]+/)
            if (t){ checked = t[1].toLowerCase() === 'x'; raw = raw.replace(/^\[[ xX]\][ \t]+/, ''); }
          }
          const html = parse_markdown(raw, o)
          return { html, checked }
        })
        tokens.push({ type: "list", ordered, items: parsedItems })
        l = j; continue
      }
      if (o.gfm){
        const hdr = line
        const alignLn = lines[l + 1] || ''
        if (/\|/.test(hdr) && /^ {0,3}\|? *:?-+:? *(?:\| *:?-+:? *)*\|? *$/.test(alignLn)){
          pushParagraph(para)
          const aligns = alignLn
            .trim().replace(/^(\|)|(\|)$/g,'')
            .split("|").map(s => s.trim()).map(s => s.startsWith(":-") && s.endsWith("-:") ? "center" : s.endsWith("-:") ? "right" : s.startsWith(":-") ? "left" : null)
          const headerCells = hdr.trim().replace(/^(\|)|(\|)$/g,'').split("|").map(s => s.trim())
          l += 2
          const rows = []
          while (l < end && /\|/.test(lines[l]) && !isBlank(lines[l])){
            rows.push(lines[l].trim().replace(/^(\|)|(\|)$/g,'').split("|").map(s => s.trim()))
            l++
          }
          tokens.push({ type: "table", header: headerCells, aligns, rows })
          continue
        }
      }
      para.push(line)
      const next = lines[l + 1] || ''
      const endPara =
        isBlank(next) ||
        /^ {0,3}(?:`{3,}|~{3,})/.test(next) ||
        /^(?: {4}|\t)/.test(next) ||
        /^ {0,3}((?:[*+-])|\d{1,9}[.)])\s+/.test(next) ||
        /^ {0,3}(#{1,6})/.test(next) ||
        /^ {0,3}>\s?/.test(next) ||
        /^ {0,3}(?:-+\s*|-{3,}|_{3,}|\*{3,})\s*$/.test(next) ||
        (o.gfm && /\|/.test(next) && /^ {0,3}\|? *:?-+:? *(?:\| *:?-+:? *)*\|? *$/.test(lines[l + 2] || ''))
      if (endPara) pushParagraph(para)
      l++
    }
    pushParagraph(para)
  }
  parseBlock(0, lines.length)
  function renderInline(s){
    if (!s) return ''
    s = s.replace(/(`+)([^`]|[^`][\s\S]*?[^`])\1/g, (_, ticks, code) => `<code>${escHtml(code)}</code>`)
    s = s.replace(/!\[([^\]]*)\]\(\s*<?([^\s)<>]+)>?\s*(?:(?:"([^"]*)"|'([^']*)'|\(([^)]+)\)))?\s*\)/g,
      (_, alt, url, t1, t2, t3) => `<img src="${escHtml(url)}" alt="${escHtml(alt)}"${t1||t2||t3?` title="${escHtml(t1||t2||t3)}"`:''}>`)
    s = s.replace(/!\[([^\]]*)\]\[([^\]]*)\]/g, (_, alt, id) => {
      const ref = refs[(id || alt).trim().replace(/\s+/g,' ').toLowerCase()]
      return ref ? `<img src="${escHtml(ref.href)}" alt="${escHtml(alt)}"${ref.title?` title="${escHtml(ref.title)}"`:''}>` : _
    })
    s = s.replace(/\[([^\]]+)\]\(\s*<?([^\s)<>]+)>?\s*(?:(?:"([^"]*)"|'([^']*)'|\(([^)]+)\)))?\s*\)/g,
      (_, text, url, t1, t2, t3) => `<a href="${escHtml(url)}"${t1||t2||t3?` title="${escHtml(t1||t2||t3)}"`:''}>${text}</a>`)
    s = s.replace(/\[([^\]]+)\]\s*\[([^\]]*)\]/g, (_, text, id) => {
      const key = (id || text).trim().replace(/\s+/g,' ').toLowerCase()
      const ref = refs[key]
      return ref ? `<a href="${escHtml(ref.href)}"${ref.title?` title="${escHtml(ref.title)}"`:''}>${text}</a>` : _
    })
    s = s.replace(/<([a-zA-Z][a-zA-Z0-9+.-]{1,31}:[^ <>"']+)>/g, (_, url) => `<a href="${escHtml(url)}">${escHtml(url)}</a>`)
    s = s.replace(/<([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})>/g, (_, mail) => `<a href="mailto:${escHtml(mail)}">${escHtml(mail)}</a>`)
    if (o.gfm){
      s = s.replace(/(?:(?<=\s)|^)(https?:\/\/[^\s<]+)(?=\s|$)/g, '<a href="$1">$1</a>')
      s = s.replace(/(?:(?<=\s)|^)(www\.[^\s<]+)(?=\s|$)/g, '<a href="http://$1">$1</a>')
    }
    s = s.replace(/\*\*([\s\S]+?)\*\*/g, '<strong>$1</strong>').replace(/__([\s\S]+?)__/g, '<strong>$1</strong>')
    s = s.replace(/\*([^*\n]+?)\*/g, '<em>$1</em>').replace(/_([^_\n]+?)_/g, '<em>$1</em>')
    if (o.gfm) s = s.replace(/~~([\s\S]+?)~~/g, '<del>$1</del>')
    s = s.replace(/ {2,}\n/g, "<br>\n")
    if (o.breaks) s = s.replace(/\n/g, "<br>\n")
    s = s.replace(/&(?!#?\w+;)/g, "&amp;").replace(/<(?!\/?[A-Za-z][^>]*>)/g, "&lt;")
    return smart(s)
  }
  let out = ''
  for (const t of tokens){
    switch (t.type){
      case "paragraph":
        out += `<p>${renderInline(t.text)}</p>\n`
        break
      case "heading": {
        const text = renderInline(t.text)
        const id = o.headerIds ? slug(text.replace(/<[^>]+>/g, '')) : null
        out += id ? `<h${t.depth} id="${id}">${text}</h${t.depth}>\n` : `<h${t.depth}>${text}</h${t.depth}>\n`
        break
      }
      case "code": {
        const cls = t.lang ? ` class="language-${escHtml(t.lang)}"` : ''
        out += `<pre><code${cls}>${escHtml(t.text)}</code></pre>\n`
        break
      }
      case "blockquote":
        out += `<blockquote>\n${t.html.trim()}\n</blockquote>\n`
        break
      case "list": {
        const tag = t.ordered ? "ol" : "ul"
        out += `<${tag}>\n`
        for (const it of t.items){
          const task = it.checked === null ? '' : `<input ${it.checked ? 'checked="" ' : ''}disabled="" type="checkbox"> `
          const body = it.html.trim().replace(/^<p>/, task + "<p>")
          out += `<li>${body}</li>\n`
        }
        out += `</${tag}>\n`
        break
      }
      case "table": {
        const ths = t.header.map((h, i) => {
          const a = t.aligns[i]
          return a ? `<th align="${a}">${renderInline(h)}</th>` : `<th>${renderInline(h)}</th>`
        }).join("\n")
        let body = ''
        for (const row of t.rows){
          const tds = row.map((cell, i) => {
            const a = t.aligns[i]
            return a ? `<td align="${a}">${renderInline(cell)}</td>` : `<td>${renderInline(cell)}</td>`
          }).join("\n")
          body += `<tr>\n${tds}\n</tr>\n`
        }
        out += `<table>\n<thead>\n<tr>\n${ths}\n</tr>\n</thead>\n` + (body ? `<tbody>\n${body}</tbody>\n` : '') + `</table>\n`
        break
      }
      case "hr":
        out += "<hr>\n"
        break
      case "html":
        out += t.text + "\n"
        break
    }
  }
  return out.trim()
}
object

%shorthands

/phlo/resources/DOM/shorthands.phlo
version 1.0
creator q-ai.nl
summary onChange, onClick and onInput event shorthands
package dom
frontend true
backend false
requires @DOM
tags dom events shorthand frontend
view

script

line 10
function onChange(els, cb){ on('change', els, cb) }
function onClick(els, cb){ on('click', els, cb) }
function onInput(els, cb){ on('input', els, cb) }
object

%store

/phlo/resources/DOM/store.phlo
version 1.1
creator q-ai.nl
summary Stateful binding engine
package dom
frontend true
backend false
requires @DOM
tags dom store binding state signals calc reactive
view

script

line 10
phlo.store = {
	signals: {},
	listeners: {},
	calcs: {},
	calcDeps: {},
	calcVals: {},
	calcTick: false,
	split: (path) => path.replace(/\]/g, '').split(/\.|\[/),
	get(path){
		if (!path) return undefined
		let ctx = phlo.store.signals
		const keys = phlo.store.split(path)
		for (let i = 0; i < keys.length; i++){
			if (ctx == null) return undefined
			ctx = ctx[keys[i]]
		}
		return ctx
	},
	setPath(path, value){
		let keys = phlo.store.split(path)
		let ctx = phlo.store.signals
		while (keys.length > 1){
			const k = keys.shift()
			ctx[k] ??= isNaN(keys[0]) ? {} : []
			ctx = ctx[k]
		}
		const k = keys[0]
		const old = ctx[k]
		if (old === value) return false
		ctx[k] = value
		return true
	},
	set(path, value){
		if (!phlo.store.setPath(path, value)) return
		phlo.store.notify(path, phlo.store.get(path))
		phlo.store.recalc(path)
		phlo.store.schedule()
	},
	on(path, cb){ (phlo.store.listeners[path] ??= new Set).add(cb) },
	off(path, cb){ phlo.store.listeners[path] && phlo.store.listeners[path].delete(cb) },
	reset(){
		phlo.store.signals = {}
		phlo.store.listeners = {}
		phlo.store.calcs = {}
		phlo.store.calcDeps = {}
		phlo.store.calcVals = {}
		phlo.store.calcTick = false
	},
	signal(path, initial){
		if (phlo.store.get(path) === undefined) phlo.store.set(path, initial)
		return { subscribe: (cb) => phlo.store.on(path, cb), unsubscribe: (cb) => phlo.store.off(path, cb) }
	},
	notify(path, val){
		const set = phlo.store.listeners[path]
		if (set) set.forEach(cb => cb(val))
	},
	match(dep, changed){
		if (!dep) return false
		if (dep === changed) return true
		return changed.startsWith(dep + '.') || changed.startsWith(dep + '[') || dep.startsWith(changed + '.') || dep.startsWith(changed + '[')
	},
	depsReady(list){
		const arr = Array.isArray(list) ? list : (list ? [list] : [])
		return arr.every(d => phlo.store.get(d) !== undefined)
	},
	evalCalc(name){
		const fn = phlo.store.calcs[name]
		if (!fn) return
		let deps = []
		let val
		try {
			const out = fn()
			if (Array.isArray(out) && out.length === 2) deps = out[0], val = out[1]
			else val = out
		}
		catch(e){
			deps = []
			val = undefined
		}
		const list = Array.isArray(deps) ? deps : (deps ? [deps] : [])
		phlo.store.calcDeps[name] = list
		if (!phlo.store.depsReady(list)) return
		const old = phlo.store.calcVals[name]
		if (old !== val){
			phlo.store.calcVals[name] = val
			const p = `calc.${name}`
			phlo.store.setPath(p, val)
			phlo.store.notify(p, val)
		}
	},
	recalc(changed){
		const names = Object.keys(phlo.store.calcs)
		for (let i = 0; i < names.length; i++){
			const name = names[i]
			const deps = phlo.store.calcDeps[name] || []
			for (let j = 0; j < deps.length; j++){
				if (phlo.store.match(deps[j], changed)){
					phlo.store.evalCalc(name)
					break
				}
			}
		}
	},
	recalcAll(){
		const names = Object.keys(phlo.store.calcs)
		for (let i = 0; i < names.length; i++) phlo.store.evalCalc(names[i])
	},
	schedule(){
		if (phlo.store.calcTick) return
		phlo.store.calcTick = true
		setTimeout(() => {
			phlo.store.calcTick = false
			phlo.store.recalcAll()
		})
	},
	proxy(base){
		return new Proxy({}, {
			get(t, k){
				if (typeof k === 'symbol') return undefined
				const seg = /^\d+$/.test(k) ? `[${k}]` : (base ? `.${k}` : String(k))
				const path = base + seg
				const v = phlo.store.get(path)
				if (v !== undefined && (typeof v !== 'object' || v === null)) return v
				return phlo.store.proxy(path)
			},
			set(t, k, v){
				const seg = /^\d+$/.test(k) ? `[${k}]` : (base ? `.${k}` : String(k))
				phlo.store.set(base + seg, v)
				return true
			},
			has(t, k){ return phlo.store.get(base + (base ? '.' : '') + String(k)) !== undefined },
			ownKeys(){ return Object.keys(phlo.store.get(base) || {}) },
			getOwnPropertyDescriptor(){ return { enumerable: true, configurable: true } }
		})
	}
}

app.store = phlo.store.proxy('')

app.mod.store = (key, value) => {
	const cur = phlo.store.get(key)
	if (JSON.stringify(cur) === JSON.stringify(value)) return
	const walk = (base, obj) => {
		if (typeof obj !== 'object' || obj === null) return phlo.store.set(base, obj)
		Object.entries(obj).forEach(([k, v]) => walk(isNaN(k) ? `${base}.${k}` : `${base}[${k}]`, v))
	}
	walk(key, value)
}

phlo.calc = new Proxy({}, {
	set(t, k, fn){
		if (typeof fn !== 'function') return false
		phlo.store.calcs[k] = fn
		phlo.store.evalCalc(k)
		setTimeout(() => phlo.store.evalCalc(k))
		return true
	},
	get(t, k){ return phlo.store.calcs[k] },
	has(t, k){ return k in phlo.store.calcs },
	deleteProperty(t, k){
		delete phlo.store.calcs[k]
		delete phlo.store.calcDeps[k]
		delete phlo.store.calcVals[k]
		return true
	}
})

app.calc = new Proxy({}, {
	get(t, k){ return phlo.store.calcVals[k] },
	has(t, k){ return k in phlo.store.calcVals },
	ownKeys(){ return Object.keys(phlo.store.calcVals) },
	getOwnPropertyDescriptor(){ return { enumerable: true, configurable: true } }
})

onExist('[data-bind]', (el) => {
	const key = el.dataset.bind
	const isCalc = key.startsWith('calc.')
	const isInput = el.tagName === 'INPUT' || el.tagName === 'SELECT' || el.tagName === 'TEXTAREA'
	const fromDom = isInput ? el.value : el.textContent
	const fromStore = phlo.store.get(key)
	const domNonEmpty = (fromDom ?? '').trim() !== ''
	const storeEmpty = fromStore === undefined || (typeof fromStore === 'string' && fromStore.trim() === '')
	const domLeads = !isCalc && domNonEmpty && storeEmpty
	const S = (v) => v == null ? '' : (typeof v === 'object' ? '' : String(v))
	const apply = (v) => {
		const s = S(v)
		if (isInput) el.value = s
		else el.textContent = s
	}
	phlo.store.on(key, apply)
	if (domLeads) phlo.store.set(key, fromDom)
	const initial = domLeads ? fromDom : fromStore
	apply(initial)
	if (!isCalc && isInput) el.oninput = (e) => phlo.store.set(key, e.target.value)
})

onExist('[data-bind-attr]', (el) => {
	const spec = el.getAttribute('data-bind-attr')
	if (!spec) return
	const BOOL = new Set(['disabled','checked','hidden','required','readonly','selected','autofocus','multiple'])
	let meta = phlo.existing.get(el)
	if (!meta || typeof meta !== 'object'){
		meta = { exist: true }
		phlo.existing.set(el, meta)
	}
	meta.attr || (meta.attr = {})
	meta.attr.cls || (meta.attr.cls = [])
	spec.split(/\s*,\s*/).filter(Boolean).forEach(pair => {
		const m = pair.match(/^\s*([^:]+)\s*:\s*(.+)\s*$/)
		if (!m) return
		const name = m[1]
		const path = m[2]
		const isCalc = path.startsWith('calc.')
		const isInputVal = name === 'value' && (el.tagName === 'INPUT' || el.tagName === 'SELECT' || el.tagName === 'TEXTAREA')
		const domVal =
			name === 'text' ? el.textContent :
			name === 'html' ? el.innerHTML :
			name === 'value' ? el.value :
			(name === 'class' ? null : el.getAttribute(name))
		const fromStore = phlo.store.get(path)
		const domNonEmpty = (domVal ?? '').trim() !== ''
		const storeEmpty = fromStore === undefined || (typeof fromStore === 'string' && fromStore.trim() === '')
		const domLeads = !isCalc && name !== 'class' && domNonEmpty && storeEmpty
		const S = (v) => v == null ? '' : (typeof v === 'object' ? '' : String(v))
		const apply = (v) => {
			if (name === 'text') el.textContent = S(v)
			else if (name === 'html') app.mod.inner(el, S(v))
			else if (name === 'value') app.mod.value(el, S(v))
			else if (name === 'class'){
				const next = Array.isArray(v) ? v : (v && typeof v === 'object') ? Object.keys(v).filter(k => v[k]) : String(v ?? '').split(/\s+/)
				const uniq = [...new Set(next.filter(Boolean))]
				const prev = meta.attr.cls
				for (let i = 0; i < prev.length; i++) el.classList.remove(prev[i])
				for (let i = 0; i < uniq.length; i++) el.classList.add(uniq[i])
				meta.attr.cls = uniq
			}
			else if (BOOL.has(name)){
				const on = !!v
				app.mod.attr(el, { [name]: on ? '' : null })
				if (name in el) el[name] = on
			}
			else app.mod.attr(el, { [name]: S(v) })
		}
		phlo.store.on(path, apply)
		if (domLeads) phlo.store.set(path, domVal)
		const initial = domLeads ? domVal : fromStore
		apply(initial)
		if (!isCalc && isInputVal) el.oninput = (e) => phlo.store.set(path, e.target.value)
	})
})
object

%template

/phlo/resources/DOM/template.phlo
version 1.0
creator q-ai.nl
summary Single Page App client-side templating
advice Add cb's to the templates object and output via apply(template: [$name => $rows, $name2 => $rows2, etc])
package dom
frontend true
backend false
tags dom template spa frontend render
view

script

line 10
app.mod.template = (template, rows) => rows.forEach(row => templates[template](...Object.values(row)))
const templates = {}
object

%timestamps

/phlo/resources/DOM/timestamps.phlo
version 1.0
creator q-ai.nl
summary DOM live timestamps
advice Create an app.tsLabels array to overwrite the tsBase labels in any language
package dom
frontend true
backend false
tags dom timestamps time live frontend
view

script

line 10
app.tsBase = {seconds: 60, minutes: 60, hours: 24, days: 7, weeks: 4, months: 13, years: 1}
const tsUpdate = () => (ranges = app.tsLabels && (tsValues = Object.values(app.tsBase)) ? Object.fromEntries(app.tsLabels.map((k, i) => [k, tsValues[i]])) : app.tsBase) && objects('[data-ts]').forEach(el => {
	let age = Math.round(Date.now() / 1000) - Number(el.dataset.ts), text = ''
	const future = age < 0
	if (future) age = -age
	for (const [range, multiplier] of Object.entries(ranges)){
		if (text) continue
		if (age / multiplier < 1.6583) text = `${Math.round(age)} ${range}`
		age /= multiplier
	}
	text ||= `${Math.round(age)} ${Object.keys(ranges).at(-1)}`
	text = `${future ? '-' : ''}${text}`
	el.innerText === text || (el.innerText = text)
})
setInterval(() => document.hidden || tsUpdate(), 1000)
setTimeout(tsUpdate, 1)
object

%toasts

/phlo/resources/DOM/toasts.phlo
version 1.0
creator q-ai.nl
summary Simple toast resource
package dom
frontend true
backend false
requires @DOM
tags dom toast notification frontend
view

script

line 10
app.mod.toast = msg => {
	obj('#toasts') || app.mod.append('body', '<div id="toasts"></div>')
	const toast = document.createElement('div')
	toast.innerHTML = msg
	toast.onclick = () => toast.remove()
	obj('#toasts').insertAdjacentElement('beforeend', toast)
	setTimeout(() => toast.remove(), 4000)
}
view

style

line 21
#toasts {
	position: fixed
	right: 10px
	top: 5px
	z-index: 1001
	> * {
		background-color: #000A
		border-radius: 10px
		clear: both
		color: white
		cursor: zoom-out
		float: right
		margin-top: 5px
		padding: 3px 6px
	}
}
object

%visible

/phlo/resources/DOM/visible.phlo
version 1.0
creator q-ai.nl
summary onVisible and onVisibleIn helpers for DOM visibility
package dom
frontend true
backend false
requires @DOM
tags dom visible intersection observer frontend
view

script

line 10
phlo.observe = []
phlo.observing = new WeakMap

const onVisible = (els, cbIn, cbOut) => onVisibleIn(els, null, cbIn, cbOut)
const onVisibleIn = (els, root, cbIn, cbOut) => phlo.observe.push({els, root, cbIn, cbOut})

app.updates.push(() => {
	const observers = []
	phlo.observe.forEach(item => objects(item.els).forEach(el => phlo.observing.has(el) || observers.push({el, root: item.root, cbIn: item.cbIn, cbOut: item.cbOut})))
	observers.forEach(item => [phlo.observing.has(item.el) || phlo.observing.set(item.el, 'observe'), (observer = new IntersectionObserver(entries => entries.forEach(entry => entry.isIntersecting ? !item.cbIn && item.cbOut ? [observer.unobserve(entry.target), item.cbOut(entry.target)] : item.cbIn(entry.target) : item.cbIn && item.cbOut && item.cbOut(entry.target)), {root: obj(item.root), threshold: .1})).observe(item.el)])
})
object

%websocket

/phlo/resources/DOM/websocket.phlo
version 1.0
creator q-ai.nl
summary Client-side WebSocket handler
package realtime
frontend true
backend false
requires @DOM
tags websocket realtime frontend dom
view

script

line 10
app.websocket = {
	get open(){
		app.options.contains('wss') && delay('websocket', app.websocket.retry, () => {
			phlo.wss?.close()
			phlo.wss = new WebSocket(`wss://${location.host}/${app.websocket.path}`)
			phlo.wss.onmessage = e => [{trans, state, ...cmds} = JSON.parse(e.data), apply(cmds, trans, state)]
			phlo.wss.onopen = e => [(cb = app.websocket.connect) && cb(e), phlo.log('🖧 Websocket connected', e), app.websocket.retry = 333]
			phlo.wss.onerror = e => [(cb = app.websocket.error) && cb(e), phlo.log('🖧 Websocket error', e)]
			phlo.wss.onclose = e => [(cb = app.websocket.close) && cb(e), phlo.log('🖧 Websocket close', e), app.websocket.retry && [app.websocket.open, app.websocket.retry *= 3]]
		})
	},
	path: 'websocket',
	send: data => phlo.wss?.readyState === 1 ? [phlo.log('🖧 app.websocket.send', '\n', data), phlo.wss.send(JSON.stringify(data))] : phlo.error('🖧 Could not send websocket data over closed socket'),
	retry: 333,
}
app.websocket.open

fields

object

%field

/phlo/resources/fields/field.phlo
type abstract class
version 1.0
creator q-ai.nl
summary Base ORM field
package fields
frontend false
backend true
tags field orm
function

function field ($type, ...$args)

line 10
phlo("field_$type", ...$args, type: $type)
static

field :: __handle

line 12
null
prop

%field -> title

line 14
ucfirst($this->name)
method

%field -> input ($record)

line 16
input(type: $this->type, name: $this->name, value: $record->{$this->name} ?? $this->default, maxlength: $this->length, placeholder: $this->placeholder, class: 'field')
method

%field -> label ($record)

line 17
$record->{$this->name};
prop

%field -> objColumns

line 19
[]
method

%field -> objValidate ($value)

line 21
if (($value === null || $value === void) && $this->required) return $this->title.' is required'
if ($value === null || $value === void) return null
if ($this->length && is_string($value) && mb_strlen($value) > $this->length) return $this->title.' is too long (max '.$this->length.')'
if ($this->pattern && is_string($value) && !preg_match('/'.str_replace('/', '\\/', $this->pattern).'/', $value)) return $this->title.' has invalid format'
if ($this->enum && !in_array($value, (array)$this->enum, true)) return $this->title.' must be one of: '.implode(', ', (array)$this->enum)
return null
object

%field_bool

/phlo/resources/fields/bool.phlo
extends field
class field_bool
version 1.0
creator q-ai.nl
summary Boolean field
package fields
frontend true
backend true
tags field boolean input
prop

%field_bool -> true

line 11
'✅'
prop

%field_bool -> false

line 12
'❌'
method

%field_bool -> label ($record)

line 14
$record->{$this->name} ? $this->true : $this->false
method

%field_bool -> input ($record)

line 15
tag('label', inner: input(type: 'checkbox', name: $this->name, value: 1, checked: $record->{$this->name} ? false : null).tag('span', class: 'slider', inner: void))
method

%field_bool -> parse ($record)

line 16
$record->{$this->name} = (bool)%payload->{$this->name};
method

%field_bool -> nullable

line 17
false
prop

%field_bool -> objColumns

line 19
[$this->name]
object

%field_child

/phlo/resources/fields/child.phlo
extends field
class field_child
version 1.0
creator q-ai.nl
summary Child relation field
package fields
frontend true
backend true
tags field relation child
prop

%field_child -> list

line 11
'count'
prop

%field_child -> change

line 12
false
prop

%field_child -> create

line 13
false
prop

%field_child -> record

line 14
'list'
method

%field_child -> count ($record)

line 16
tag('a', href: slash.($record::$uriRecord ?? $record::class).slash.$record->id.slash.$this->name, class: 'async', inner: $record->getCount($this->name).space.$this->title)
method

%field_child -> last ($record)

line 17
$record->getLast($this->name)
method

%field_child -> label ($record)

line 18
implode(loop($record->{$this->name}, fn($child) => $this->link($child))) ?: dash
method

%field_child -> input ($record)

line 19
$this->label($record)
method
line 20
tag('a', href: slash.($record::$uriRecord ?? $record::class).slash.$record->id, class: 'async', inner: $record)
object

%field_date

/phlo/resources/fields/date.phlo
extends field
class field_date
version 1.0
creator q-ai.nl
summary Date field
package fields
frontend false
backend true
tags field date
prop

%field_date -> handle

line 11
true
prop

%field_date -> objColumns

line 13
[$this->name]
object

%field_datetime

/phlo/resources/fields/datetime.phlo
extends field
class field_datetime
version 1.0
creator q-ai.nl
summary Date-time field
package fields
frontend true
backend true
tags field datetime
prop

%field_datetime -> handle

line 11
true
prop

%field_datetime -> change

line 12
!in_array($this->name, ['created', 'changed'])
prop

%field_datetime -> create

line 13
!in_array($this->name, ['created', 'changed'])
method

%field_datetime -> label ($record)

line 15
($value = $record->{$this->name}) ? tag('i', class: 'icon clock-'.$this->labelIconClass(time() - $value), inner: void).tag('span', data_ts: $record->{$this->name}, inner: time_human($record->{$this->name})) : dash
method

%field_datetime -> labelIconClass ($value)

line 16
$value > 86400 ? 'red' : ($value > 3600 ? 'yellow' : 'blue')
method

%field_datetime -> input ($record)

line 17
input(type: 'datetime-local', name: $this->name, value: date('Y-m-d\TH:i', $record->{$this->name}), class: 'field')
method

%field_datetime -> parse ($record)

line 18
if ($this->name === 'created') $record->created ??= time()
elseif ($this->name === 'changed') $record->{$this->name} = time()
elseif ($payload = %payload->{$this->name}) $record->{$this->name} = strtotime($payload)
prop

%field_datetime -> objColumns

line 24
[$this->name]
object

%field_email

/phlo/resources/fields/email.phlo
extends field_text
class field_email
version 1.0
creator q-ai.nl
summary Email field
package fields
frontend false
backend true
tags field email
view

%field_email -> label ($record)

line 12
<a href="mailto:{{ $record->{$this->name} }}">{{ $record->{$this->name} }}</a>
object

%field_file

/phlo/resources/fields/file.phlo
extends field
class field_file
version 1.0
creator q-ai.nl
summary File field
package fields
frontend true
backend true
tags field file upload
prop

%field_file -> canDelete

line 11
true
prop

%field_file -> delete

line 12
'Delete file?'
prop

%field_file -> path

line 13
files
prop

%field_file -> uri

line 14
'/files/'
prop

%field_file -> length

line 15
100
method

%field_file -> label ($record)

line 17
($filename = $record->{$this->name}) ? tag('a', href: $this->uri.$record->{$this->name.'_token'}.slash.rawurlencode($filename), target: 'file', inner: tag('i', class: 'icon '.pathinfo($filename, PATHINFO_EXTENSION), inner: void).$filename) : dash
method

%field_file -> input ($record)

line 18
$input = void
$file = ($filename = $record->{$this->name}) ? $this->read($record) : null
$input .= lf.tab.tag('div', class: 'file', inner: $file ? tag('i', class: "icon $file->ext", inner: void).$filename : '-select-')
$input .= lf.tab.input(type: 'file', class: 'file-input', name: $this->name, accept: '.pdf')
$file && $this->canDelete && $input .= lf.tab.tag('div', inner: tag('label', inner: input(type: 'checkbox', name: $this->name.'Delete')." $this->delete"))
return $input.lf
method

%field_file -> parse ($record)

line 27
$file = %payload->{$this->name};
if ($delete = %payload->{$this->name.'Delete'}) unset(%payload->{$this->name.'Delete'})
if ($record->{$this->name} && $delete && $this->canDelete) $record->{$this->name} = $record->{$this->name.'_token'} = null
if (is_a($file, 'file')){
	$token = $file->token
	if (!$this->write($file)) return
	$record->{$this->name} = $file->shortenTo($this->length)
	$record->{$this->name.'_token'} = $token
}
method

%field_file -> read ($record, $path = null)

line 39
$filename = $record->{$this->name};
$token = $record->{$this->name.'_token'};
return %file(($path ?? $this->path).($info = substr($token, 0, 2).slash.substr($token, 2).dot.pathinfo($filename, PATHINFO_EXTENSION)), name: $filename, info: $info)
method

%field_file -> write ($file)

line 45
file_exists($dest = $this->writePath($file)) || $file->move($dest) || error("Couldn't write: $dest")
method

%field_file -> writePath ($file, $path = null)

line 46
$token = $file->token
$path = ($path ?? $this->path).substr($token, 0, 2)
is_dir($path) || mkdir($path) || error("Couldn't create: $path")
return $path.slash.substr($token, 2).dot.$file->ext
prop

%field_file -> objColumns

line 53
[$this->name, $this->name.'_token']
view

script

line 55
on('click', '.input.file .file', (file) => file.nextElementSibling.click())
on('change', '.input.file .file-input', (input) => input.previousElementSibling.innerText = input.files[0].name)
object

%field_image

/phlo/resources/fields/image.phlo
extends field_file
class field_image
version 1.0
creator q-ai.nl
summary Image field
package fields
frontend true
backend true
tags field image upload
prop

%field_image -> delete

line 11
'Delete image?'
prop

%field_image -> uri

line 12
'/images/'
prop

%field_image -> path

line 13
images
prop

%field_image -> thumbPath

line 14
thumbs
prop

%field_image -> thumbSize

line 15
64
prop

%field_image -> thumbUri

line 16
'/thumbs/'
method

%field_image -> label ($record)

line 18
($filename = $record->{$this->name}) ? tag('a', href: $this->uri.($url = $record->{$this->name.'_token'}.slash.rawurlencode($filename)), target: 'image', inner: tag('img', src: $this->thumbUri.$url)) : dash
method

%field_image -> input ($record)

line 19
$input = void
$file = $record->{$this->name} ? $this->read($record, $this->thumbPath) : null
$input .= lf.tab.tag('img', src: $file ? $this->thumbUri.$record->{$this->name.'_token'}.slash.rawurlencode($record->{$this->name}) : '/image.select.jpg', class: 'image')
$input .= lf.tab.input(type: 'file', class: 'image-input',  name: $this->name, accept: 'image/*', data_size: $this->thumbSize)
$file && $this->canDelete && $input .= lf.tab.tag('div', inner: tag('label', inner: input(type: 'checkbox', name: $this->name.'Delete')." $this->delete"))
return $input.lf
method

%field_image -> write ($file)

line 28
%img($file->file)->scale($this->thumbSize, $this->thumbSize)->save($this->writePath($file, $this->thumbPath)) && $file->move($this->writePath($file))
prop

%field_image -> objColumns

line 30
[$this->name, $this->name.'_token']
view

script

line 32
on('click', '.input.image .image', (img) => img.nextElementSibling.click())
on('change', '.input.image .image-input', (input) => imageResizer(input.files[0], input.dataset.size, input.dataset.size, (image) => input.previousElementSibling.src = image))
object

%field_many

/phlo/resources/fields/many.phlo
extends field
class field_many
version 1.0
creator q-ai.nl
summary Many-to-many relation field
package fields
frontend true
backend true
tags field relation many
prop

%field_many -> list

line 11
'label'
prop

%field_many -> record

line 12
'label'
prop

%field_many -> create

line 13
false
prop

%field_many -> change

line 14
false
method

%field_many -> count ($record)

line 16
$record->getCount($this->name)
method

%field_many -> label ($record)

line 17
loop($record->{$this->name}, fn($relation) => $this->link($relation), lf) ?: dash
method
line 18
tag('a', href: slash.($record::$uriRecord ?? $record::class).slash.$record->id, class: 'async', inner: $record)
method

%field_many -> input ($record)

line 19
$this->label($record)
prop

%field_many -> objColumns

line 21
[]
object

%field_multiselect

/phlo/resources/fields/multiselect.phlo
extends field
class field_multiselect
version 1.0
creator q-ai.nl
summary Multi-select via checkboxes; slaat de keuze als CSV op in één verborgen veld (geen save-aanpassing nodig).
package fields
frontend false
backend true
tags field select multi
method

%field_multiselect -> label ($record)

line 11
($v = (string)($record->{$this->name} ?? '')) !== '' ? $v : dash
method

%field_multiselect -> input ($record)

line 13
$current = array_filter(array_map('trim', explode(',', (string)($record->{$this->name} ?? ''))))
$js = "var f=this.closest('.field');f.querySelector('input.msv').value=[...f.querySelectorAll('input.msc:checked')].map(c=>c.value).join(',')"
$boxes = loop((array)$this->options, fn($o) => tag('label', tag('input', type: 'checkbox', value: (string)$o, class: 'msc', checked: in_array((string)$o, $current) ? true : void, onchange: $js).esc((string)$o), class: 'ms-opt'), void)
return tag('input', type: 'hidden', name: $this->name, value: implode(',', $current), class: 'msv').tag('div', $boxes ?: tag('span', 'geen opties', class: 'muted'), class: 'ms-grid')
prop

%field_multiselect -> objColumns

line 20
[$this->name]
object

%field_number

/phlo/resources/fields/number.phlo
extends field
class field_number
version 1.0
creator q-ai.nl
summary Number field
package fields
frontend true
backend true
tags field number
prop

%field_number -> decimals

line 11
prop

%field_number -> length

line 12
5
prop

%field_number -> min

line 13
method

%field_number -> label ($record)

line 15
number_format($record->{$this->name}, $this->decimals, comma, dot)
method

%field_number -> input ($record)

line 16
input(type: 'number', name: $this->name, value: $record->{$this->name} ?? $this->default, step: $this->decimals ? dot.str_repeat('0', $this->decimals - 1).'1' : null, min: $this->min, class: 'field')
prop

%field_number -> objColumns

line 18
[$this->name]
object

%field_parent

/phlo/resources/fields/parent.phlo
extends field
class field_parent
version 1.0
creator q-ai.nl
summary Parent relation field
package fields
frontend false
backend true
tags field relation parent
method

%field_parent -> label ($record)

line 11
is_a($obj = $record->{$this->name}, 'model') ? $this->link($obj) : dash
method

%field_parent -> input ($record)

line 12
select(name: $this->name, inner: loop($this->options, fn($parent) => '<option'.($parent->id === $record->{$this->name}?->id ? ' selected' : void).' value="'.$parent->id.'">'.$parent, void))
method
line 13
tag('a', href: slash.($record::$uriRecord ?? $record::class).slash.$record->id, class: 'async', inner: $content ?? $record)
prop

%field_parent -> options

line 14
$this->obj::records()
prop

%field_parent -> objColumns

line 16
[$this->name]
object

%field_password

/phlo/resources/fields/password.phlo
extends field
class field_password
version 1.0
creator q-ai.nl
summary Password field
package fields
frontend false
backend true
tags field password
prop

%field_password -> list

line 11
false
prop

%field_password -> required

line 12
true
prop

%field_password -> minlength

line 13
8
prop

%field_password -> placeholder

line 14
'New password'
method

%field_password -> input ($record)

line 16
input(type: $this->type, name: $this->name, placeholder: $this->placeholder, class: 'field')
method

%field_password -> label ($record)

line 17
'••••••••'
method

%field_password -> parse ($record)

line 18
($password = trim(%payload->{$this->name})) && $record->{$this->name} = password_hash($password, PASSWORD_BCRYPT)
prop

%field_password -> objColumns

line 20
[$this->name]
object

%field_price

/phlo/resources/fields/price.phlo
class field_price
version 1.0
creator q-ai.nl
summary Price field
package fields
frontend false
backend true
tags field price money
extends field_number
prop

%field_price -> decimals

line 11
2
object

%field_select

/phlo/resources/fields/select.phlo
extends field
class field_select
version 1.0
creator q-ai.nl
summary Select field
package fields
frontend false
backend true
tags field select
method

%field_select -> input ($record)

line 11
select(name: $this->name, inner: loop($this->options, fn($option) => "<option".($record->{$this->name} === $option ? ' selected' : void).">$option", void))
prop

%field_select -> objColumns

line 13
[$this->name]
object

%field_text

/phlo/resources/fields/text.phlo
extends field
class field_text
version 1.0
creator q-ai.nl
summary Text field
package fields
frontend false
backend true
tags field text
prop

%field_text -> length

line 11
100
prop

%field_text -> multiline

line 12
$this->length > 250
method

%field_text -> label ($record)

line 14
($value = $record->{$this->name}) === null ? dash : strtr(strlen($value) > 100 ? substr($value, 0, 80).'...' : $value, [lf => br])
method

%field_text -> input ($record)

line 15
$this->multiline ? $this->inputMulti($record) : $this->inputField($record)
method

%field_text -> inputField ($record)

line 16
input(type: $this->type, name: $this->name, value: ($value = $record->{$this->name}) ? esc($value) : $this->default, maxlength: $this->length, placeholder: $this->placeholder, class: 'field')
method

%field_text -> inputMulti ($record)

line 17
textarea(name: $this->name, inner: ($value = $record->{$this->name}) ? esc($value) : $this->default ?? void, placeholder: $this->placeholder, class: 'field')
prop

%field_text -> objColumns

line 19
[$this->name]
object

%field_token

/phlo/resources/fields/token.phlo
extends field
class field_token
version 1.0
creator q-ai.nl
summary Token field
package fields
frontend false
backend true
tags field token
prop

%field_token -> length

line 11
8
prop

%field_token -> default

line 12
token($this->length)
prop

%field_token -> create

line 13
false
prop

%field_token -> change

line 14
false
prop

%field_token -> search

line 15
true
method

%field_token -> label ($record)

line 17
tag('div', inner: esc($record->{$this->name}))
method

%field_token -> parse ($record)

line 18
$record->{$this->name} ??= $this->default
prop

%field_token -> objColumns

line 20
[$this->name]
object

%field_virtual

/phlo/resources/fields/virtual.phlo
extends field
class field_virtual
version 1.0
creator q-ai.nl
summary Virtual field
package fields
frontend false
backend true
tags field virtual
prop

%field_virtual -> create

line 11
false
prop

%field_virtual -> change

line 12
false
prop

%field_virtual -> objColumns

line 14
[]
object

%field_wysiwyg

/phlo/resources/fields/wysiwyg.phlo
extends field
class field_wysiwyg
version 1.0
creator q-ai.nl
summary WYSIWYG field
package fields
frontend true
backend true
tags field wysiwyg editor
method

%field_wysiwyg -> input ($record)

line 11
$value = $record->{$this->name} ?? $this->default ?? void
$toolbar = tag('div', class: 'toolbar', inner: tag('button', type: 'button', data_command: 'bold', inner: '<b>B</b>').tag('button', type: 'button', data_command: 'italic', inner: '<i>I</i>').tag('button', type: 'button', data_command: 'underline', inner: '<u>U</u>').tag('button', type: 'button', data_command: 'insertUnorderedList', inner: '•').tag('button', type: 'button', data_command: 'insertOrderedList', inner: '1.'))
$editor = tag('div', class: 'editor', contenteditable: 'true', inner: $value)
$hiddenInput = textarea(name: $this->name, class: 'hidden-value', inner: $value)
return tag('div', class: 'wysiwyg-container', inner: "$toolbar$editor$hiddenInput")
prop

%field_wysiwyg -> objColumns

line 19
[$this->name]
view

script

line 21
on('click', '.wysiwyg-container .toolbar button', (button, e) => {
	e.preventDefault()
	const command = button.dataset.command
	document.execCommand(command, false, null)
})

on('input', '.wysiwyg-container .editor', (editor) => {
	const container = editor.closest('.wysiwyg-container')
	const hiddenInput = container.querySelector('textarea.hidden-value')
	hiddenInput.value = editor.innerHTML
})
on('click', '.wysiwyg-container .editor a', (a) => window.open(a.href))

Functions

function

field($type, ...$args)

/phlo/resources/fields/field.phlo line 10
phlo("field_$type", ...$args, type: $type)

files

object

%CSV

/phlo/resources/files/CSV.phlo
version 1.0
creator q-ai.nl
summary CSV reader resource
package files
frontend false
backend true
tags file csv reader import
static

CSV :: __handle

line 9
"CSV/$path$filename"
method

%CSV -> __construct (string $filename, ?string $path = null)

line 10
$path ??= data
$this->objFile = $path.strtr($filename, [slash => dot]).'.csv'
if (is_readable($this->objFile)) $this->objRead()
readonly

%CSV -> objFile:string

line 16
method

%CSV -> objRead

line 18
$fp = fopen($this->objFile, 'r+')
$headers = str_replace([dq, cr, lf], void, fgets($fp))
$delimiter = substr_count($headers, comma) > substr_count($headers, semi) ? comma : semi
$headers =  explode($delimiter, $headers)
while ($row = fgetcsv($fp, null, $delimiter)) $this->objData[] = array_combine($headers, $row)
fclose($fp)
object

%DOCX

/phlo/resources/files/DOCX.phlo
version 1.0
creator q-ai.nl
summary DOCX reader resource
package files
frontend false
backend true
requires php-ext:zip
tags file docx word reader
method

%DOCX -> __construct (string $file)

line 10
$zip = new ZipArchive()
if ($zip->open($file) !== true) dx('error opening docx', $file)
$xml = $zip->getFromName('word/document.xml')
$zip->close()
if (!$xml) dx('error reading document.xml')
$text = preg_replace('/<\/w:p>/', "\n", $xml)
$text = strip_tags($text)
$text = html_entity_decode($text, ENT_QUOTES | ENT_XML1, 'UTF-8')
$this->text = trim(preg_replace('/[ \t]+/', ' ', $text))
$this->paragraphs = array_values(array_filter(explode("\n", $this->text), fn($p) => trim($p) !== void))
static

DOCX :: toText (string $file):string

line 23
(new static($file))->text
object

%file

/phlo/resources/files/file.phlo
version 1.0
creator q-ai.nl
summary File resource
package files
frontend false
backend true
tags file filesystem io
static

file :: __handle

line 9
"file/$file".($name ? "/$name" : void)
method

%file -> __construct (public string $file, ?string $name = null, $contents = null, ...$args)

line 10
$name && $this->name = $name
is_string($contents) && $this->write($contents)
$args && $this->objImport(...$args)
method

%file -> append (string $data)

line 16
file_put_contents($this->file, $data, FILE_APPEND | LOCK_EX)
prop

%file -> basename

line 17
pathinfo($this->file, PATHINFO_BASENAME)
method

%file -> base64

line 18
base64_encode($this->contents)
method

%file -> contents

line 19
file_get_contents($this->file)
method

%file -> contentsINI (bool $parse = true)

line 20
parse_ini_string($this->contents, true, $parse ? INI_SCANNER_TYPED : INI_SCANNER_RAW)
method

%file -> contentsJSON ($assoc = null)

line 21
json_decode($this->contents, $assoc)
method

%file -> copy ($to)

line 22
copy($this->file, $to)
method

%file -> created

line 23
filectime($this->file)
method

%file -> createdAge

line 24
age($this->created)
method

%file -> createdHuman

line 25
time_human($this->created)
method

%file -> curl ($type = null, $filename = null)

line 26
new CURLFile($this->file, $type, $filename)
method

%file -> delete

line 27
first($deleted = $this->exists && unlink($this->file), debug($deleted ? "Deleted $this->basename" : "Could not delete $this->basename"))
method

%file -> exists

line 28
file_exists($this->file)
prop

%file -> ext

line 29
pathinfo($this->name, PATHINFO_EXTENSION)
prop

%file -> filename

line 30
pathinfo($this->file, PATHINFO_FILENAME)
method

%file -> getLine

line 31
($line = fgets($this->pointer)) === false ? false : rtrim($line)
method

%file -> getLength (int $length)

line 32
fread($this->pointer, $length)
method

%file -> is (string $file)

line 33
$file === $this->file
method

%file -> md5

line 34
md5_file($this->file)
prop

%file -> mime

line 35
mime($this->name)
method

%file -> modified

line 36
filemtime($this->file)
method

%file -> modifiedAge

line 37
age($this->modified)
method

%file -> modifiedHuman

line 38
time_human($this->modified)
method

%file -> move ($to)

line 39
rename($this->file, $to) && $this->file = $to
prop

%file -> name

line 40
$this->basename
method

%file -> output ($download = false)

line 41
output($this->contents, $this->name, $download)
prop

%file -> path

line 42
realpath(pathinfo($this->file, PATHINFO_DIRNAME)).slash
prop

%file -> pathRel

line 43
str_starts_with($this->file, app) ? substr($this->file, strlen(app)) : $this->file
prop

%file -> pointer

line 44
fopen($this->file, 'r+')
method

%file -> readable

line 45
is_readable($this->file)
method

%file -> src

line 46
"data:$this->mime;base64,$this->base64"
method

%file -> size

line 47
filesize($this->file)
method

%file -> sizeHuman (int $precision = 0)

line 48
size_human($this->size, $precision)
method

%file -> sha1

line 49
sha1_file($this->file)
method

%file -> shortenTo (int $length)

line 50
strlen($this->name) <= $length ? $this->name : substr($this->name, 0, $length - strlen($this->ext) - 3).dot.dot.dot.$this->ext
method

%file -> title

line 51
ucfirst(strtr(pathinfo($this->name, PATHINFO_FILENAME), [us => space]))
method

%file -> token ($length = 20)

line 52
token($length, $this->sha1)
method

%file -> type

line 53
substr($this->mime, 0, strpos($this->mime, slash))
method

%file -> touch

line 54
touch($this->file)
method

%file -> writable

line 55
is_writable($this->file)
method

%file -> writeINI ($data, bool $deleteEmpty = false)

line 56
$this->write(!$deleteEmpty || $data ? loop($data, fn($value, $key) => $key.' = '.dq.strtr($value, [dq => bs.dq, lf => '\n']).dq, lf).lf : void, $deleteEmpty)
method

%file -> writeJSON ($data, bool $deleteEmpty = false)

line 57
$this->write(!$deleteEmpty || $data ? json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : void, $deleteEmpty)
method

%file -> writeJSONplain ($data, bool $deleteEmpty = false)

line 58
$this->write(!$deleteEmpty || $data ? json_encode($data) : void, $deleteEmpty)
method

%file -> write (string $data, bool $deleteEmpty = false)

line 59
if (!$data && $deleteEmpty) return $this->delete
if ($written = file_put_contents($this->file, $data, LOCK_EX) !== false) debug('Written '.$this->basename.' ('.$this->sizeHuman.')')
else error('Could not write '.$this->file)
return $written
method

%file -> objInfo

line 66
array_combine($keys = array_merge(['file', 'name', 'exists'], $this->exists ? ['sizeHuman', 'createdHuman', 'modifiedHuman', 'mime'] : []), loop($keys, fn($arg) => $this->$arg))
object

%img

/phlo/resources/files/img.phlo
version 1.0
creator q-ai.nl
summary GD image resource
package files
frontend false
backend true
tags image gd file graphics
static

img :: detect ($data)

line 9
$header = substr($data, 0, 12)
if (substr($header, 0, 3) === "\xFF\xD8\xFF") return 'jpg'
if (substr($header, 0, 8) === "\x89PNG\x0D\x0A\x1A\x0A") return 'png'
if (substr($header, 0, 6) === 'GIF87a' || substr($header, 0, 6) === 'GIF89a') return 'gif'
if (substr($header, 0, 4) === 'RIFF' && substr($header, 8, 4) === 'WEBP') return 'webp'
if (substr($header, 0, 2) === "BM") return 'bmp'
if (substr($header, 0, 4) === "\x49\x49\x2A\x00" || substr($header, 0, 4) === "\x4D\x4D\x00\x2A") return 'tiff'
static

img :: search ($search)

line 19
$q = strtr($search, [dash => '+'])
$DOM = HTTP("http://images.google.it/images?as_q=$q&hl=it&imgtbs=z&btnG=Cerca+con+Google&as_epq=&as_oq=&as_eq=&imgtype=&imgsz=m&imgw=&imgh=&imgar=&as_filetype=&imgc=&as_sitesearch=&as_rights=&safe=images&as_st=y", agent: true)
$sources = regex_all('/var s=\'data:image\/jpeg;base64,([^\']{1000,})/', $DOM)[1]
usort($sources, fn($a, $b) => strlen($b) <=> strlen($a))
$sources = array_slice($sources, 0, 5)
shuffle($sources)
$source = current($sources)
$source = base64_decode($source)
return $source
static

img :: __handle

line 31
"img/$file"
method

%img -> __construct (public string $file)

line 32
prop

%img -> src:GdImage

line 34
imagecreatefromstring(file_get_contents($this->file))
prop

%img -> width

line 35
imagesx($this->src)
prop

%img -> height

line 36
imagesy($this->src)
method

%img -> scale ($width = null, $height = null, $crop = false)

line 38
if (!$width && !$height) return $this
$srcW = $this->width
$srcH = $this->height
$doCrop = ($crop && $width && $height)
if ($width && $height) $scale = $doCrop ? max($width / $srcW, $height / $srcH) : min($width / $srcW, $height / $srcH)
elseif ($width) $scale = $width / $srcW
else $scale = $height / $srcH
if ($scale >= 1) return $this
$scaledW = (int)round($srcW * $scale)
$scaledH = (int)round($srcH * $scale)
$destW = ($width && $height && $doCrop) ? (int)$width : $scaledW
$destH = ($width && $height && $doCrop) ? (int)$height : $scaledH
$offsetX = 0
$offsetY = 0
if ($width && $height && $doCrop){
	$offsetX = (int)-round(($scaledW - $destW) / 2)
	$offsetY = (int)-round(($scaledH - $destH) / 2)
	if ($crop === 'top') $offsetY = 0
	elseif ($crop === 'bottom') $offsetY = (int)-($scaledH - $destH)
}
$destImg = imagecreatetruecolor($destW, $destH)
imagealphablending($destImg, false)
imagesavealpha($destImg, true)
imagecopyresampled($destImg, $this->src, $offsetX, $offsetY, 0, 0, $scaledW, $scaledH, $srcW, $srcH)
$this->src = $destImg
$this->width = $destW
$this->height = $destH
return $this
method

%img -> ext ($file = null)

line 69
strtolower(pathinfo($file ?? $this->file, PATHINFO_EXTENSION))
method

%img -> source ($format = null)

line 71
ob_start()
$this->write($format)
return ob_get_clean()
method

%img -> save ($file = null)

line 77
$file && $this->file = $file
return $this->write(null, $this->file)
method

%img -> write ($format = null, $file = null)

line 82
$format ??= $this->ext()
if ($format === 'png') return imagepng($this->src, $file, 8)
if ($format === 'gif') return imagegif($this->src, $file)
if ($format === 'webp'){
	imageistruecolor($this->src) || imagepalettetotruecolor($this->src)
	return imagewebp($this->src, $file)
}
return imagejpeg($this->src, $file, 85)
object

%INI

/phlo/resources/files/INI.phlo
version 1.0
creator q-ai.nl
summary Generic INI resource
package files
frontend false
backend true
tags file ini config parser
prop

%INI -> objFile:string

line 9
static

INI :: __handle

line 11
"INI/$path$filename".(!$parse ? '/0' : void)
method

%INI -> __construct (string $filename, ?string $path = null, bool $parse = true)

line 12
$path ??= data
$this->objFile = $path.strtr($filename, [slash => dot]).'.ini'
if (is_readable($this->objFile)) $this->objRead($parse)
method

%INI -> objRead ($parse = true)

line 18
last($this->objData = parse_ini_file($this->objFile, true, $parse ? INI_SCANNER_TYPED : INI_SCANNER_RAW), $this->objChanged = false, $this)
method

%INI -> objWrite

line 19
file_put_contents($this->objFile, loop($this->objData, fn($value, $key) => $key.' = '.dq.strtr($value, [dq => bs.dq, lf => '\n']).dq, lf).lf, LOCK_EX)
method

%INI -> __destruct

line 21
$this->objChanged && $this->objWrite()
object

%JSON

/phlo/resources/files/JSON.phlo
version 1.0
creator q-ai.nl
summary Generic JSON resource
package files
frontend false
backend true
requires json_read json_write
tags file json storage parser
static

JSON :: __handle

line 10
"JSON/$path$filename".(is_bool($assoc) ? slash.(int)$assoc : void)
method

%JSON -> __construct (string $filename, ?string $path = null, $assoc = null)

line 11
$path ??= data
$this->objFile = "$path$filename.json"
if (is_readable($this->objFile)) $this->objRead($assoc)
readonly

%JSON -> objFile:string

line 17
method

%JSON -> objTouch

line 19
$this->objChanged = true
method

%JSON -> objRead ($assoc = null)

line 20
last($data = json_read($this->objFile, $assoc), $this->objData = $assoc || is_array($data) ? $data : get_object_vars($data), $this->objChanged = false, $this)
method

%JSON -> objWrite ($data, $flags = null)

line 21
first($written = json_write($this->objFile, $data, $flags), $written && $this->objChanged = false)
method

%JSON -> __destruct

line 23
$this->objChanged && $this->objWrite($this->objData)
object

%PDF

/phlo/resources/files/PDF.phlo
version 1.0
creator q-ai.nl
summary PDF generator and reader
package files
frontend false
backend true
tags file pdf reader generator
static

PDF :: toText (string $file):string

line 9
$process = proc_open('pdftotext '.escapeshellarg($file).' -', [['pipe', 'r'], ['pipe', 'w'], ['pipe', 'w']], $pipes)
if (!is_resource($process)) return null
fclose($pipes[0])
$text = stream_get_contents($pipes[1])
fclose($pipes[1])
$error = stream_get_contents($pipes[2])
fclose($pipes[2])
($code = proc_close($process)) && error("PDFToText Error: pdftotext command failed with code $code. Error: $error")
return rtrim($text, "\f")
prop

%PDF -> title

line 21
null
prop

%PDF -> author

line 22
null
prop

%PDF -> subject

line 23
null
prop

%PDF -> keywords

line 24
null
prop

%PDF -> creator

line 25
'Phlo '.phlo.' (https://phlo.tech/)'
prop

%PDF -> filename

line 27
'Download.pdf'
prop

%PDF -> mode

line 28
'D'
method

%PDF -> fromHTML ($HTML)

line 30
$mpdf = new \Mpdf\Mpdf
$this->title && $mpdf->SetTitle($this->title)
$this->author && $mpdf->SetAuthor($this->author)
$this->subject && $mpdf->SetSubject($this->subject)
$this->keywords && $mpdf->SetKeywords($this->keywords)
$this->creator && $mpdf->SetCreator($this->creator)
$mpdf->WriteHTML($HTML)
return $mpdf->Output($this->filename, $this->mode)
object

%XLSX

/phlo/resources/files/XLSX.phlo
version 1.0
creator q-ai.nl
summary XLSX reader resource
package files
frontend false
backend true
requires php-ext:zip
tags file xlsx excel reader
method

%XLSX -> __construct (string $file)

line 10
$sheets = []
$shared = []
$sheetNames = []
$zip = new ZipArchive()
if ($zip->open($file) !== true) dx('error opening zip', $file)
for ($i = 0; $i < $zip->numFiles; $i++){
	$name = $zip->getNameIndex($i)
	if ($name === false) continue
	if (dirname($name) === 'xl/worksheets') $sheets[filter_var($name, FILTER_SANITIZE_NUMBER_INT)] = $zip->getFromIndex($i)
	elseif ($name === 'xl/sharedStrings.xml'){
		$xml = $zip->getFromIndex($i)
		if (!preg_match_all('/<t[^>]*>(.*?)<\/t>/s', $xml, $m)) dx('error reading shared lib')
		$shared = array_map(fn($t) => html_entity_decode($t, ENT_QUOTES | ENT_XML1, 'UTF-8'), $m[1])
	}
	elseif ($name === 'xl/workbook.xml'){
		$xml = $zip->getFromIndex($i)
		if (!preg_match_all('/<sheet[^>]*name="([^"]+)"[^>]*sheetId="([0-9]+)"/', $xml, $m)) dx('error reading workbook')
		$sheetNames = $m[1]
	}
}
$zip->close()
$toIndex = fn($letters) => array_reduce(str_split(strtoupper($letters)), fn($n, $c) => $n * 26 + ord($c) - 64, 0) - 1
$isShared = fn($attrs) => preg_match('/\bt="s"\b/', $attrs) === 1
foreach ($sheets AS $sheetID => $sheet){
	$name = $sheetNames[$sheetID - 1] ?? 'Sheet '.$sheetID
	if (!preg_match('/<row[^>]*>(.+)<\/row>/s', $sheet, $m)) dx('error parsing sheet')
	$rowsXml = preg_split('/<\/row><row[^>]*>/', $m[1]) ?: []
	$headerMap = []
	$isHeader = true
	foreach ($rowsXml AS $rowXml){
		$rowXml = preg_replace('/<c([^>]*)\/>/', '<c$1></c>', $rowXml)
		if (!preg_match_all('/<c r="([A-Z]+)[0-9]+"([^>]*)>(?:<f\b[^>]*\/?>)?(?:(?:<v>([^<]*)<\/v>)|(?:<is>.*?<t[^>]*>(.*?)<\/t>.*?<\/is>))?<\/c>/s', $rowXml, $mm)) dx('error parsing row', $rowXml)
		if ($isHeader){
			foreach (array_keys($mm[0]) AS $i){
				$col = $toIndex($mm[1][$i])
				$attrs = $mm[2][$i]
				$valV = $mm[3][$i] ?? null
				$valIS = $mm[4][$i] ?? null
				$val = $valV !== null && $valV !== void ? $valV : ($valIS !== null && $valIS !== void ? html_entity_decode($valIS, ENT_QUOTES | ENT_XML1, 'UTF-8') : null)
				$txt = $isShared($attrs) ? ($shared[$val] ?? null) : $val
				$headerMap[$col] = $txt !== null && $txt !== void ? $txt : 'col'.$col
			}
			$isHeader = false
		}
		else {
			$rowArr = []
			foreach (array_keys($mm[0]) AS $i){
				$col = $toIndex($mm[1][$i])
				$attrs = $mm[2][$i]
				$valV = $mm[3][$i] ?? null
				$valIS = $mm[4][$i] ?? null
				$val = $valV !== null && $valV !== void ? $valV : ($valIS !== null && $valIS !== void ? html_entity_decode($valIS, ENT_QUOTES | ENT_XML1, 'UTF-8') : null)
				$key = $headerMap[$col] ?? 'col'.$col
				$rowArr[$key] = $isShared($attrs) ? ($shared[$val] ?? null) : $val
			}
			$this->objData[$name][] = $rowArr
		}
	}
}

security

object

%audit

/phlo/resources/security/audit.phlo
version 1.1
creator q-ai.nl
summary Audit log for model mutations (opt-in via static idColumn/objAudit). Schema: resources/security/audit.sql
package security
frontend false
backend true
requires @MySQL
tags audit log compliance traceability
method

%audit -> log ($model, $action, $before = [], $after = [], $exclude = [])

line 10
$class = is_object($model) ? get_class($model) : (string)$model
$pk = (is_string($class) && class_exists($class) && property_exists($class, 'idColumn')) ? $class::$idColumn : 'id'
$id = is_object($model) ? ($model->$pk ?? $model->id ?? null) : null
if ($id === null) return
$before = (array)$before
$after = (array)$after
foreach ($exclude AS $col) unset($before[$col], $after[$col])
$changes = $action === 'update' ? $this->diff($before, $after) : ($action === 'create' ? $after : $before)
%MySQL->query(
	'INSERT INTO audit_log (ts, user, model, record_id, action, changes, ip) VALUES (?, ?, ?, ?, ?, ?, ?)',
	time(),
	isset(%session->user) ? (int)%session->user : null,
	$class,
	(string)$id,
	$action,
	json_encode($changes, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
	(string)($_SERVER['REMOTE_ADDR'] ?? null),
)
method

%audit -> diff ($before, $after)

line 31
$changed = []
foreach ($after AS $col => $newVal){
	$oldVal = $before[$col] ?? null
	if ($oldVal !== $newVal) $changed[$col] = ['from' => $oldVal, 'to' => $newVal]
}
return $changed
method

%audit -> history ($model, $recordId, $limit = 50)

line 40
$class = is_object($model) ? get_class($model) : (string)$model
return %MySQL->query(
	'SELECT * FROM audit_log WHERE model=? AND record_id=? ORDER BY ts DESC LIMIT ?',
	$class, (string)$recordId, (int)$limit,
)->fetchAll(\PDO::FETCH_OBJ)
method

%audit -> byUser ($userId, $fromTs = 0, $limit = 100)

line 48
return %MySQL->query(
	'SELECT * FROM audit_log WHERE user=? AND ts >= ? ORDER BY ts DESC LIMIT ?',
	(int)$userId, (int)$fromTs, (int)$limit,
)->fetchAll(\PDO::FETCH_OBJ)
method

%audit -> purge ($olderThanSeconds = 31536000)

line 55
%MySQL->query('DELETE FROM audit_log WHERE ts < ?', time() - $olderThanSeconds)
object

%creds

/phlo/resources/security/creds.phlo
version 1.0
creator q-ai.nl
summary Credentials resolver from env and ini sources
package security
frontend false
backend true
tags credentials env ini secrets configuration
method

%creds -> __construct (?array $values = null)

line 9
$values ??= $this->resolve()
foreach ($values AS $key => $value){
	$this->$key = is_array($value) ? new static($value) : new \SensitiveParameterValue((string)$value)
}
method

%creds -> resolve

line 16
$data = []
$this->merge($data, $this->loadINI(data.'creds.ini'))
$this->merge($data, $this->envValues(false))
$this->merge($data, $this->envValues(true))
return $data
method

%creds -> loadINI (string $file):array

line 24
if (!is_file($file)) return []
$ini = parse_ini_file($file, true, INI_SCANNER_RAW)
return is_array($ini) ? $ini : []
method

%creds -> envValues (bool $hostScoped = false):array

line 30
$out = []
$prefix = $hostScoped ? ('PHLO_'.$this->hostKey().'__') : 'PHLO__'
$sources = []
is_array($_ENV ?? null) && $sources[] = $_ENV
is_array($_SERVER ?? null) && $sources[] = $_SERVER
is_array($env = getenv()) && $sources[] = $env
foreach ($sources AS $source){
	foreach ($source AS $key => $value){
		$key = (string)$key
		if (!str_starts_with($key, $prefix)) continue
		$path = substr($key, strlen($prefix))
		if (!$path) continue
		$this->envAssign($out, explode('__', $path), (string)$value)
	}
}
return $out
method

%creds -> hostKey

line 49
$host = strtoupper(%req->host)
$host = preg_replace('/[^A-Z0-9]+/', '_', $host)
return trim($host, '_')
method

%creds -> envAssign (array &$target, array $parts, string $value):void

line 55
$parts = array_values(array_filter(loop($parts, fn($part) => trim($part)), 'strlen'))
if (!$parts) return
$node = &$target
$last = count($parts) - 1
foreach ($parts AS $i => $part){
	if ($i === $last){
		$node[$part] = $value
		return
	}
	if (!isset($node[$part]) || !is_array($node[$part])) $node[$part] = []
	$node = &$node[$part]
}
method

%creds -> merge (array &$base, array $add):void

line 70
foreach ($add AS $key => $value){
	if (isset($base[$key]) && is_array($base[$key]) && is_array($value)){
		$this->merge($base[$key], $value)
		continue
	}
	$base[$key] = $value
}
method

%creds -> objGet ($key)

line 80
if ($key === 'toArray') return loop($this->objData, fn($value) => is_a($value, 'SensitiveParameterValue') ? $value->getValue() : $value)
if (isset($this->objData[$key]) && is_a($this->objData[$key], '\SensitiveParameterValue')) return $this->objData[$key]->getValue()
method

%creds -> objInfo

line 85
loop($this->objData, fn($value) => is_a($value, '\SensitiveParameterValue') ? str_repeat('*', strlen($value->getValue())) : $value)
object

%CSRF

/phlo/resources/security/CSRF.phlo
version 1.0
creator q-ai.nl
summary Rotating async CSRF protection for Phlo requests
package security
frontend true
backend true
requires @session token payload
provides app.mod.csrf
tags csrf security async forms
view

%CSRF -> view

line 11
<meta name=csrf content="$this->token">
prop

%CSRF -> token

line 12
%session->csrf ??= token(32)
method

%CSRF -> verify

line 13
hash_equals($this->token, (string)($_SERVER['HTTP_X_CSRF_TOKEN'] ?? void))
method

%CSRF -> update

line 14
arr(csrf: $this->token = %session->csrf = token(32))
view

script

line 16
app.mod.csrf = value => obj('meta[name="csrf"]').content = value
object

%JWT

/phlo/resources/security/JWT.phlo
version 1.0
creator q-ai.nl
summary Sign and verify compact HS256 JSON Web Tokens (RFC 7519), secure by default
package security
frontend false
backend true
requires php-ext:hash
tags jwt jws hs256 token auth security
method

%JWT -> __construct (public string $secret, public string $issuer = void, public int $leeway = 30)

line 10
strlen($this->secret) >= 32 || error('JWT secret must be at least 32 bytes', 500)
method

%JWT -> sign (array $claims, int $ttl = 3600):string

line 14
$now = time()
$claims['iat'] = $now
$claims['exp'] = $now + $ttl
if ($this->issuer !== void) $claims['iss'] = $this->issuer
$body = $this->encode(['alg' => 'HS256', 'typ' => 'JWT']).dot.$this->encode($claims)
return $body.dot.$this->sig($body)
method

%JWT -> verify (string $token):array

line 23
$token = preg_replace('/^Bearer\s+/i', void, trim($token))
$parts = explode(dot, $token)
count($parts) === 3 || error('JWT malformed', 401)
[$h, $p, $s] = $parts
$header = (array)json_decode((string)$this->decode($h), true)
($header['alg'] ?? void) === 'HS256' || error('JWT algorithm not allowed', 401)
hash_equals($this->sig($h.dot.$p), $s) || error('JWT signature invalid', 401)
$claims = (array)json_decode((string)$this->decode($p), true)
$now = time()
isset($claims['nbf']) && $now + $this->leeway < $claims['nbf'] && error('JWT not yet valid', 401)
isset($claims['exp']) && $now - $this->leeway >= $claims['exp'] && error('JWT expired', 401)
$this->issuer === void || ($claims['iss'] ?? void) === $this->issuer || error('JWT issuer mismatch', 401)
return $claims
method

%JWT -> sig (string $body):string

line 39
$this->encode(hash_hmac('sha256', $body, $this->secret, true))
method

%JWT -> encode ($data):string

line 40
rtrim(strtr(base64_encode(is_string($data) ? $data : (string)json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)), '+/', '-_'), eq)
method

%JWT -> decode (string $data):string

line 41
(string)base64_decode(strtr($data, '-_', '+/'))
object

%rate

/phlo/resources/security/rate.phlo
version 1.2
creator q-ai.nl
summary Rate-limit (fixed window) op rate_limit-tabel. Schema: resources/security/rate.sql
package security
frontend false
backend true
requires @MySQL
tags rate limit throttle abuse
method

%rate -> check ($key, $limit, $windowSeconds, $storage = 'db')

line 10
if ($storage === 'apcu') return $this->checkApcu($key, $limit, $windowSeconds)
$now = time()
$row = %MySQL->query('SELECT count, window_start FROM rate_limit WHERE rkey=?', $key)->fetchObject('obj') ?: null
if ($row && ($now - (int)$row->window_start) < $windowSeconds){
	if ((int)$row->count >= $limit) return false
	%MySQL->query('UPDATE rate_limit SET count=count+1 WHERE rkey=?', $key)
	return true
}
%MySQL->query('INSERT INTO rate_limit (rkey, count, window_start) VALUES (?,1,?) ON DUPLICATE KEY UPDATE count=1, window_start=VALUES(window_start)', $key, $now)
return true
method

%rate -> checkApcu ($key, $limit, $windowSeconds)

line 23
$window = (int)floor(time() / $windowSeconds) * $windowSeconds
$apcuKey = 'phlo.rate.'.$key.':'.$window
$count = apcu_inc($apcuKey, 1, $success, $windowSeconds)
if (!$success) apcu_store($apcuKey, 1, $windowSeconds)
return $count <= $limit
method

%rate -> status ($key, $limit, $windowSeconds)

line 31
$now = time()
$row = %MySQL->query('SELECT count, window_start FROM rate_limit WHERE rkey=?', $key)->fetchObject('obj') ?: null
if (!$row || ($now - (int)$row->window_start) >= $windowSeconds) return obj(used: 0, limit: $limit, resetIn: 0)
return obj(used: (int)$row->count, limit: $limit, resetIn: max(0, ((int)$row->window_start + $windowSeconds) - $now))
method

%rate -> reset ($key)

line 38
%MySQL->query('DELETE FROM rate_limit WHERE rkey=?', $key)
method

%rate -> purge ($olderThanSeconds = 604800)

line 39
%MySQL->query('DELETE FROM rate_limit WHERE window_start < ?', time() - $olderThanSeconds)
object

%security

/phlo/resources/security/security.phlo
version 1.0
creator q-ai.nl
summary Generic security resource
package security
frontend true
backend true
requires @session token
tags security csp nonce headers
prop

%security -> whitelist

line 10
[]
method

%security -> setNonce

line 12
%app->nonce = token(8)
method

%security -> frameProtect ($mode = 'DENY')

line 14
%res->header('X-Frame-Options', $mode)
method

%security -> frameWhitelist

line 15
$this->whitelist ? ' '.implode(space, array_map(fn($d) => "https://*.$d", (array)$this->whitelist)) : void
method

%security -> strict

line 17
$this->base
if (%req->async) return
%res->header('Cache-Control', 'no-store')
$nonce = $this->setNonce
%res->header('Content-Security-Policy', "default-src 'self'; script-src 'nonce-$nonce'; style-src 'self' 'nonce-$nonce'; img-src 'self' data:; font-src 'self'; connect-src 'self'; form-action 'self'; object-src 'none'; frame-src 'self'$this->frameWhitelist; frame-ancestors 'none'; base-uri 'self'")
method

%security -> basic

line 24
$this->base
if (%req->async) return
%res->header('Content-Security-Policy', "default-src 'self'; script-src 'self'".(debug ? " 'unsafe-inline'" : '')."; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; form-action 'self'; object-src 'none'; frame-src 'self'$this->frameWhitelist; frame-ancestors 'none'; base-uri 'self'")
method

%security -> marketing

line 29
$this->base
if (%req->async) return
%res->header('Content-Security-Policy', "default-src 'self'; script-src 'self'".(debug ? " 'unsafe-inline'" : '')."; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; form-action 'self'; object-src 'none'; frame-src 'self'$this->frameWhitelist; frame-ancestors 'none'; base-uri 'self'")
method

%security -> api

line 34
%res->header('Content-Security-Policy', "default-src 'none'; frame-ancestors 'none'")
%res->header('X-Content-Type-Options', 'nosniff')
%res->header('Referrer-Policy', 'no-referrer')
method

%security -> base

line 40
%res->header('Referrer-Policy', 'strict-origin-when-cross-origin')
%res->header('X-Content-Type-Options', 'nosniff')
%req->async || %res->header('Cross-Origin-Opener-Policy', 'same-origin')
%req->async || %res->header('Cross-Origin-Resource-Policy', 'same-origin')
%req->async || %res->header('Access-Control-Allow-Origin', %req->base)
%req->async || %res->header('X-Frame-Options', 'DENY')

Functions

function

decrypt($encrypted, $key):string|false

/phlo/resources/security/encryption.phlo line 10
($d = base64_decode($encrypted, true)) !== false && strlen($d) >= SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ? sodium_crypto_secretbox_open(substr($d, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES), substr($d, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES), hash('sha256', $key, true)) : false
function

encrypt($data, $key):string

/phlo/resources/security/encryption.phlo line 8
base64_encode(($nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES)).sodium_crypto_secretbox($data, $nonce, hash('sha256', $key, true)))
function

token(int $length = 8, ?string $input = null)

/phlo/resources/security/token.phlo line 8
	$length || error('Token must have a minimum length above 0', 500)
	$alphabet = 'abcdefghijklmnopqrstuvwxyz'
	$alphabetLength = strlen($alphabet)
	$limit = intdiv(256, $alphabetLength) * $alphabetLength
	$token = void
	$buffer = void
	$state = is_null($input) ? null : hash('sha256', (string)$input, true)
	while (strlen($token) < $length){
		if ($buffer === void){
			if (is_null($state)) $buffer = random_bytes(32)
			else {
				$state = hash('sha256', $state, true)
				$buffer = $state
			}
		}
		$byte = ord($buffer[0])
		$buffer = substr($buffer, 1)
		if ($byte >= $limit) continue
		$token .= $alphabet[$byte % $alphabetLength]
	}
	return $token

We use essential cookies to make this site work. With your permission we also use analytics to improve the site.