Core

object

%cookies

/phlo/resources/cookies.phlo

Cookies data object

Reads and writes a cookie as a property. Every cookie it sets is httponly, samesite Lax and secure on an https request, so a script cannot read it and it does not travel to another site. That also means a value you need in the browser does not belong here. lifetimeDays sets how long they last; unsetting the property removes the cookie on the visitor's side too.

cookiessessionbrowserweb
method

%cookies -> controller

line 10
This controller retrieves the current state of cookies and assigns it to the objData property.
this->objData = $_COOKIE
prop

%cookies -> lifetimeDays:int

line 12
Sets the lifetime of cookies in days.
180
method

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

line 14
Sets a cookie with the specified key and value, along with optional parameters for expiration, path, security, and SameSite attributes.
$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):void

line 22
Removes a cookie by unsetting it from the local object data and the global $_COOKIE array, and sets its expiration date to the past.
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

Language and translation resource

%lang prints the current app language through its view, so it drops straight into a link or an attribute. Beyond that it is the translation layer behind nl() and en(): a phrase is hashed, looked up in langs/<lang>.ini, and translated by the AI in the background when it is missing, so the first visitor reads the source text and the next one reads the translation. Editing a source phrase changes its hash and orphans the old translation, so a rewrite costs a re-translation.

langtranslationi18nlocaleai
function

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

line 11
Translates the given text into Dutch using the specified arguments for formatting.
%lang->translation('nl', $text, ...$args)
function

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

line 12
This function retrieves a translation for the specified text in English, optionally formatting it with additional arguments.
%lang->translation('en', $text, ...$args)
static

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

line 14
Executes a batch translation asynchronously, decoding JSON input and saving the translations if successful.
%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
This function retrieves the current language setting for the application, allowing for localization of views.
%app->lang
prop

%lang -> model:string

line 23
This function retrieves the model associated with the specified language identifier.
'gpt-4o-mini'
prop

%lang -> instructions

line 24
Defines a set of instructions for the Phlo programming language.
void
static

lang :: fileCache:array

line 25
lang::$fileCache is a static property that stores cached language files for efficient retrieval during runtime.
[]
method

%lang -> file ($lang):string

line 27
Retrieves the configuration file for the specified language, using the format 'langs.$lang.ini'.
langs.$lang.'.ini'
method

%lang -> escape ($value):string

line 29
Escapes special characters in a string for safe output in HTML, replacing backslashes, double quotes, and line feeds with their respective escape sequences.
strtr((string)$value, [bs => bs.bs, dq => bs.dq, lf => '\n'])
method

%lang -> unescape ($value):string

line 30
This function unescapes a given string by replacing escape sequences with their corresponding characters.
strtr(strtr($value, [bs.bs => "\x01", bs.dq => dq, '\n' => lf]), ["\x01" => bs])
method

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

line 32
Extracts and processes a line value from a given string, removing surrounding quotes if present and unescaping any special characters.
$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):array

line 38
Reads all key-value pairs from a specified file and returns them as an associative array. If the file does not exist or is not a valid file, it returns an empty array.
$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):?string

line 54
Finds one line with a binary search over the raw bytes of the translation file.
$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):?string

line 100
Looks up a value in the language file cache based on the provided hash, updating the cache if the file has changed.
$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):void

line 115
Writes the whole file under a temporary name and renames it into place.
$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:string

line 129
Retrieves the context from the app author regarding the purpose and domain, if available; otherwise, it returns void.
($instr = trim($this->instructions ?? void)) !== void ? lf.'Context from the app author about purpose and domain: '.$instr : void
prop

%lang -> browser:?string

line 131
Extracts the preferred language from the 'Accept-Language' HTTP header, returning the first matching language code from the application's supported languages.
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:?string

line 132
Retrieves the language preference from cookies and checks if it is a valid option in the application's available languages, returning the language if valid or null otherwise.
($lang = %cookies->lang) && %app->langs[$lang] ? $lang : null
method

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

line 133
Detects the language of the given text and returns the ISO 639-1 code. If detection fails, it returns a specified fallback language code, defaulting to 'en'.
$res = %AI->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):string

line 142
Generates a hash based on the provided text and a prefix from the specified language.
$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):string

line 143
Translates the given text from a specified language to the application's current language, handling missing translations asynchronously.
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):string

line 162
Translates a given text from one ISO 639-1 language to another using AI, while preserving markdown formatting and capitalization.
if ($from === $to) return $text
return %AI->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):array

line 171
Translates a batch of text from one language to another using AI, returning the translations in a numbered format.
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 = %AI->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

%lastmod

/phlo/resources/lastmod.phlo

Build-time stamp of when each page's source last changed, read at runtime for sitemap lastmod and for showing a date to the reader

Call lastmod::stamp from a build hook, so the dates are read where the sources are. Resolving them at runtime does not work on a release node, which carries no .phlo sources, and a deploy rewrites every mtime, so every page would claim to have changed on the day it was deployed. Pages in %app->pages resolve by convention; declare prop %lastmod.sources as uri => file path for the rest. A page that resolves to nothing simply gets no lastmod, which is the right answer: a crawler that catches a site inventing them stops trusting the field domain-wide.

seositemaplastmodbuildstamp
static

lastmod :: file ($dir = null):string

line 13
Where the map lives, beside the generated PHP so it travels with a release.
($dir ?: php).'lastmod.json'
prop

%lastmod -> sources:array

line 17
Sources the convention cannot find, as a uri to file path map.
[]
static

lastmod :: resolve ($uri):?string

line 20
The file most likely to render this page.
$slug = trim((string)$uri, slash)
$names = $slug === void ? ['home.phlo', 'page.home.phlo'] : [$slug.'.phlo', 'page.'.$slug.'.phlo', strtr($slug, [slash => dot]).'.phlo']
foreach ($names AS $name) if (is_file($file = app.$name)) return $file
return null
static

lastmod :: day ($file):?string

line 27
$file && is_file($file) ? date('Y-m-d', filemtime($file)) : null
static

lastmod :: uriOf ($page):string

line 29
is_string($page) ? $page : (string)(is_array($page) ? ($page['uri'] ?? void) : ($page->uri ?? void))
static

lastmod :: stamp ($dir = null):array

line 31
$map = []
foreach ((array)(%app->pages ?? []) AS $page) ($d = static::day(static::resolve(static::uriOf($page)))) && $map[static::uriOf($page)] = $d
foreach ((array)%lastmod->sources AS $uri => $file) ($d = static::day($file)) && $map[$uri] = $d
ksort($map)
file_put_contents($target = static::file($dir), json_encode($map, jsonPretty))
return ['file' => $target, 'pages' => count($map)]
prop

%lastmod -> map:array

line 40
is_file($file = static::file()) ? (array)json_decode((string)file_get_contents($file), true) : []
method

%lastmod -> for ($uri):?string

line 42
$this->map[$uri] ?? null
object

%manifest

/phlo/resources/manifest.phlo

PWA web app manifest: declare the body, get the manifest.json route, head link and correct serving

Set the body from your app (prop %manifest.body => arr(...)) and put %manifest->head in your head view, because that head view is the only thing that links the manifest from a page. %manifest itself is the manifest document, the way %seo is the sitemap. Apps with multiple manifest variants keep their own routes and serve each body through manifest::output().

manifestpwawebmanifestinstallstandalone
route

route GET manifest.json

line 10
if (!%manifest->body) return false
manifest::output(%manifest->body)
prop

%manifest -> body

line 15
null
prop

%manifest -> maxAge:int

line 16
60
static

manifest :: encode ($body):string

line 18
json_encode($body, jsonPretty)
static

manifest :: output ($body, $maxAge = null):void

line 20
%res->header('Cache-Control', 'public, max-age='.($maxAge ?? %manifest->maxAge))
output(static::encode($body), type: 'application/manifest+json')
view

%manifest -> view

line 25
manifest::encode(%manifest->body)
view

%manifest -> head

line 27
<link rel=manifest href=/manifest.json>
object

%manual

/phlo/resources/manual.phlo

Self-writing manual at /manual: app description, source reflection and recent commits, plus an optional AI summary

Nothing to configure: include the resource and the page describes the app it runs in. It reads data/app.md, reflects the live source and reads git log, so it never goes stale. A product layer under paths.resources appears as its own section once it carries a layer.json in its repo root and a heading in its README; without that file a layer stays out of sight, which is how the framework itself stays out. The page is standalone (inline css, no javascript, no layout) and every render that changes the content is stored as data/manual.html without its session token, so the last state survives without the app. The markdown of data/app.md is rendered server-side, so the page needs no runtime and no namespace configuration. The AI summary is optional and keyed on data/app.md, so it costs nothing per visit; without the AI resource or a key the rest of the page still works. Put the route behind your auth gate and add manual to release.exclude, since a manual carrying the source does not belong on a customer server.

manualdocsreflectionai
static

manual :: instruction

line 11
'Summarise what this application does and what has changed recently. Write in the same language as the description, at most eight sentences, running text without lists or headings. Do not open with a sentence about what you are going to do.'
prop

%manual -> translate:bool

line 13
function_exists('en')
prop

%manual -> labels:array

line 14
arr(
	title:    'Manual',
	lead:     'This page comes from the source itself: the description, what the code exposes and the latest changes.',
	state:    'State',
	unknown:  'unknown',
	summary:  'In short',
	about:    'What this app is',
	files:    'Files',
	routes:   'Routes',
	changes:  'Recent changes',
	why:      'Why',
	own:      'Codebase',
	shared:   'Shared with the sibling app',
	noKey:    'No AI key is configured, so there is no summary. The rest of this page comes straight from the source and works without one.',
	noAnswer: 'The model returned nothing.',
	noSummary:'The summary could not be fetched: ',
	noInfo:   'This app does not carry a data/app.md yet.',
	noNodes:  'This file carries no nodes; it consists of markup or script.',
)
method

%manual -> label ($key):string

line 33
$this->translate ? en($this->labels[$key]) : $this->labels[$key]
static

manual :: appInfo:string

line 35
(string)(reflect::appInfo() ?: void)
static

manual :: commits (string $path, int $limit = 12):array

line 37
if (!$path || !is_dir($path)) return []
$field = perc.'x1f'
$record = perc.'x1e'
$format = perc.'h'.$field.perc.'ad'.$field.perc.'s'.$field.perc.'b'.$record
$command = 'git -C '.escapeshellarg($path).' log -'.(int)$limit.' --no-merges --date=iso --format='.$format.' 2>/dev/null'
$raw = (string)@shell_exec($command)
$out = []
foreach (array_filter(explode("\x1e", $raw), 'trim') AS $entry){
	$parts = explode("\x1f", trim($entry))
	if (count($parts) < 3) continue
	$when = strtotime($parts[1]) ?: 0
	$out[] = obj(
		hash: $parts[0],
		when: $when,
		date: $when ? date('d-m-Y H:i', $when) : $parts[1],
		subject: $parts[2],
		body: trim((string)($parts[3] ?? void)),
	)
}
return $out
static

manual :: repos:array

line 60
$path = app
$dirs = [%manual->label('own') => $path]
$config = json_decode((string)@file_get_contents($path.'data/app.json'), true)
foreach ((array)($config['paths']['resources'] ?? []) AS $dir){
	$dir = rtrim((string)$dir, slash).slash
	$label = is_dir($dir) ? static::dirLabel($dir) : void
	if ($label === void || isset($dirs[$label])) continue
	$dirs[$label] = $dir
}
$roots = []
$out = []
foreach ($dirs AS $label => $dir){
	$root = trim((string)@shell_exec('git -C '.escapeshellarg($dir).' rev-parse --show-toplevel 2>/dev/null'))
	if ($root === void || isset($roots[$root])) continue
	$roots[$root] = true
	$out[$label] = $dir
}
return $out
static

manual :: commitList (int $limit = 20):array

line 81
$all = []
foreach (static::repos() AS $label => $dir){
	foreach (static::commits($dir) AS $commit){
		$commit->origin = $label
		$all[] = $commit
	}
}
usort($all, fn($a, $b) => $b->when <=> $a->when)
return array_slice($all, 0, $limit)
static

manual :: head:string

line 93
$commits = static::commits(app, 1)
return (string)($commits[0]->hash ?? void)
static

manual :: routes:array

line 98
(array)reflect::compactRoutes()
static

manual :: routeGroups:array

line 100
$groups = []
foreach (static::routes() AS $route){
	$file = basename((string)($route['file'] ?? 'unknown'))
	$groups[$file][] = $route
}
ksort($groups)
return $groups
static

manual :: nodesByFile:array

line 110
if (isset(%req->docsNodes)) return (array)%req->docsNodes
$found = []
foreach (['route', 'view', 'static', 'method', 'prop'] AS $type){
	foreach ((array)reflect::find($type, null, true, 'all') AS $row){
		$row = (array)$row
		$file = basename((string)($row['file'] ?? void), '.phlo')
		if ($file === void) continue
		$found[$file][] = obj(
			type: $type,
			name: (string)($row['name'] ?? void),
			args: (string)($row['args'] ?? void),
			ret: (string)($row['type'] ?? void),
			summary: (string)($row['summary'] ?? void),
		)
	}
}
%req->docsNodes = $found
return $found
static

manual :: fileRow (string $file):obj

line 131
$meta = (new build_file($file))->meta
$key = basename($file, '.phlo')
return obj(
	name: basename($file),
	summary: trim((string)($meta['summary'] ?? void)),
	advice: trim((string)($meta['advice'] ?? void)),
	lines: count(file($file) ?: []),
	nodes: static::nodesByFile()[$key] ?? [],
)
static

manual :: sources:array

line 143
$path = app
$own = %manual->label('own')
$config = json_decode((string)@file_get_contents($path.'data/app.json'), true)
if (!is_array($config)) return []

$dirs = [$path => $own]
foreach ((array)($config['paths']['resources'] ?? []) AS $dir){
	$dir = rtrim((string)$dir, slash).slash
	if (!is_dir($dir)) continue
	$label = static::dirLabel($dir)
	if ($label !== void) $dirs[$dir] = $label
}

$groups = []
foreach ($dirs AS $dir => $label) $groups[$label] = []
foreach (glob($path.'*.phlo') ?: [] AS $file) $groups[$own][] = static::fileRow($file)
foreach ((array)($config['resources'] ?? []) AS $name){
	foreach ($dirs AS $dir => $label){
		if ($label === $own) continue
		$file = $dir.$name.'.phlo'
		if (!is_file($file)) continue
		$groups[$label][] = static::fileRow($file)
		break
	}
}
return array_filter($groups)
static

manual :: dirLabel (string $dir):string

line 172
$root = trim((string)@shell_exec('git -C '.escapeshellarg($dir).' rev-parse --show-toplevel 2>/dev/null'))
$marker = $root === void ? null : json_decode((string)@file_get_contents($root.slash.'layer.json'), true)
if (is_array($marker) && ($marker['docs'] ?? true) !== false){
	$label = trim((string)($marker['label'] ?? void))
	if ($label !== void) return $label
	preg_match('~^#\s+(.+)~m', (string)@file_get_contents($root.slash.'README.md'), $match)
	if (($label = trim((string)($match[1] ?? void))) !== void) return $label
}
if (str_starts_with(rtrim($dir, slash), rtrim(dirname(rtrim(app, slash)), slash))) return %manual->label('shared')
return void
static

manual :: cacheFile:string

line 185
data.'manual.json'
static

manual :: cached:?obj

line 187
$file = static::cacheFile()
if (!is_file($file)) return null
$row = json_decode((string)file_get_contents($file))
return $row instanceof \stdClass ? obj(...(array)$row) : null
static

manual :: configured:bool

line 194
if (!class_exists('AI') || !class_exists('creds')) return false
return (string)(%creds->OpenAI ?? void) !== void || (string)(%creds->Claude ?? void) !== void
static

manual :: appName:string

line 199
defined('id') ? (string)id : (string)%app->title
static

manual :: model:string

line 201
$set = class_exists('setting') ? (string)(setting::value('docs.model') ?: void) : void
return $set ?: 'gpt-5.4-mini'
static

manual :: summary:obj

line 206
$info = static::appInfo()
$head = $info === void ? void : md5($info)
$row = static::cached()
if ($row && (string)$row->hash === $head && $head !== void) return $row
if (!static::configured()) return obj(hash: $head, text: void, missing: true)

$lines = ['This is the description of the app:']
$lines[] = mb_substr(static::appInfo(), 0, 4000)
$lines[] = 'And these are the latest changes:'
foreach (static::commits(app) AS $commit) $lines[] = $commit->date.space.$commit->subject
try {
	$answer = phlo('AI')->chat(model: static::model(), system: static::$instruction, user: implode(lf, $lines))
	$text = trim((string)($answer->answer ?? void))
}
catch (\Throwable $e){
	return obj(hash: $head, text: void, error: mb_substr($e->getMessage(), 0, 200))
}
if ($text === void) return obj(hash: $head, text: void, error: %manual->label('noAnswer'))
@file_put_contents(static::cacheFile(), json_encode(['hash' => $head, 'text' => $text, 'written' => time()]))
return obj(hash: $head, text: $text)
static

manual :: pageFile:string

line 229
data.'manual.html'
static

manual :: store (string $body, string $page):bool

line 231
$mark = '<!-- manual '.md5($body).' -->'
$file = static::pageFile()
if (is_file($file) && str_contains((string)@file_get_contents($file), $mark)) return false
return (bool)@file_put_contents($file, preg_replace('~<meta name="csrf"[^>]*>\s*~', void, $page).lf.$mark.lf)
static

manual :: mdBlocks (string $md):array

line 238
$lines = explode(lf, str_replace(cr, void, $md))
$count = count($lines)
$blocks = []
$para = []
$i = 0
while ($i < $count){
	$line = $lines[$i]
	if (trim($line) === void){
		static::mdPara($para, $blocks)
		$i++
		continue
	}
	if (preg_match('/^ {0,3}(`{3,}|~{3,})/', $line, $match)){
		static::mdPara($para, $blocks)
		$fence = '/^ {0,3}'.preg_quote($match[1][0], slash).'{3,}\s*$/'
		$body = []
		$i++
		while ($i < $count && !preg_match($fence, $lines[$i])){
			$body[] = $lines[$i]
			$i++
		}
		$i++
		$blocks[] = obj(type: 'code', text: implode(lf, $body))
		continue
	}
	if (preg_match('/^ {0,3}(#{1,6})\s+(.*?)\s*#*$/', $line, $match)){
		static::mdPara($para, $blocks)
		$blocks[] = obj(type: 'heading', depth: min(4, max(2, strlen($match[1]))), text: trim($match[2]))
		$i++
		continue
	}
	if (preg_match('/^ {0,3}([-*_])(?:\s*\1){2,}\s*$/', $line)){
		static::mdPara($para, $blocks)
		$blocks[] = obj(type: 'hr')
		$i++
		continue
	}
	if (preg_match('/^ {0,3}>/', $line)){
		static::mdPara($para, $blocks)
		$body = []
		while ($i < $count && preg_match('/^ {0,3}>\s?(.*)$/', $lines[$i], $match)){
			$body[] = $match[1]
			$i++
		}
		$blocks[] = obj(type: 'quote', text: trim(implode(lf, $body)))
		continue
	}
	if (str_contains($line, pipe) && preg_match('/^ {0,3}\|? *:?-+:? *(?:\| *:?-+:? *)*\|? *$/', (string)($lines[$i + 1] ?? void))){
		static::mdPara($para, $blocks)
		$head = static::mdCells($line)
		$rows = []
		$i += 2
		while ($i < $count && str_contains($lines[$i], pipe)){
			$rows[] = static::mdCells($lines[$i])
			$i++
		}
		$blocks[] = obj(type: 'table', head: $head, rows: $rows)
		continue
	}
	if (($marker = static::mdMarker($line)) !== null){
		static::mdPara($para, $blocks)
		$raw = []
		while ($i < $count){
			if (trim($lines[$i]) === void){
				if (static::mdBreaks(static::mdMarker((string)($lines[$i + 1] ?? void)), $marker)) break
				$raw[] = void
				$i++
				continue
			}
			$here = static::mdMarker($lines[$i])
			if ($here === null && !preg_match('/^ {2,}\S/', $lines[$i])) break
			if ($here !== null && static::mdBreaks($here, $marker)) break
			$raw[] = $lines[$i]
			$i++
		}
		$blocks[] = obj(type: 'list', ordered: $marker->ordered, items: static::mdItems($raw, $marker->indent))
		continue
	}
	$para[] = $line
	$i++
}
static::mdPara($para, $blocks)
return $blocks
static

manual :: mdMarker (string $line):?obj

line 324
if (!preg_match('/^( *)([*+-]|\d{1,9}\.)\s+/', $line, $match)) return null
return obj(indent: strlen($match[1]), ordered: ctype_digit($match[2][0]))
static

manual :: mdBreaks (?obj $here, obj $marker):bool

line 329
$here === null || ($here->indent <= $marker->indent && $here->ordered !== $marker->ordered)
static

manual :: mdPara (array &$para, array &$blocks):void

line 331
$text = trim(implode(lf, $para))
$para = []
if ($text !== void) $blocks[] = obj(type: 'para', text: $text)
static

manual :: mdCells (string $line):array

line 337
array_map('trim', explode(pipe, trim(trim($line), pipe)))
static

manual :: mdItems (array $lines, int $indent):array

line 339
$groups = []
$group = null
foreach ($lines AS $line){
	if (preg_match('/^( *)([*+-]|\d{1,9}\.)\s+(.*)$/', $line, $match) && strlen($match[1]) <= $indent){
		if ($group !== null) $groups[] = $group
		$group = [$match[3]]
		continue
	}
	if ($group === null) continue
	$group[] = preg_replace('/^ {1,'.($indent + 2).'}/', void, $line)
}
if ($group !== null) $groups[] = $group
$items = []
foreach ($groups AS $group){
	$raw = trim(implode(lf, $group))
	$checked = null
	if (preg_match('/^\[([ xX])\]\s*(.*)$/s', $raw, $match)){
		$checked = strtolower($match[1]) === 'x'
		$raw = $match[2]
	}
	$sub = static::mdBlocks($raw)
	$text = void
	if ($sub && $sub[0]->type === 'para') $text = array_shift($sub)->text
	$items[] = obj(text: $text, checked: $checked, blocks: $sub)
}
return $items
static

manual :: mdInline (string $text):string

line 368
$out = void
foreach (preg_split('/(`+[^`]*`+)/', $text, -1, PREG_SPLIT_DELIM_CAPTURE) AS $index => $part){
	if ($index % 2) $out .= '<code>'.esc(trim($part, bt)).'</code>'
	else $out .= static::mdFormat($part)
}
return $out
static

manual :: mdFormat (string $text):string

line 377
$out = preg_replace('/\*\*(.+?)\*\*/s', '<strong>$1</strong>', esc($text))
$out = preg_replace('/(?<![*\w])\*([^*\n]+)\*(?![*\w])/', '<em>$1</em>', $out)
return preg_replace('/\[([^\]]+)\]\(\s*((?:https?:|mailto:|[\/#])\S*?)\s*\)/', '<a href="$2">$1</a>', $out)
route

route both GET manual

line 383
%app->title = static::appName()
%app->css = []
%app->js = []
%app->defer = []
$body = %manual->page()
$page = view($body, title: %manual->label('title'), css: [], js: [], inline: true, ns: 'manual')
static::store($body, $page)
return $page
view

%manual -> page

line 394
<main.docs>
	<header.docs__head>
		<h1>{{ manual::appName() }}</h1>
		<p.docs__sub>{{ %manual->label('lead') }} {{ %manual->label('state') }} {{ static::head() ?: %manual->label('unknown') }}.</p>
	</header>
	<div#docs-summary>{{ %manual->summaryBlock }}</div>
	{{ %manual->infoBlock }}
	{{ %manual->filesBlock }}
	{{ %manual->routesBlock }}
	{{ %manual->commitsBlock }}
</main>
view

%manual -> summaryBlock

line 407
$row = static::summary()
if ($row->missing) return indentView(lf.'<section class="docs__box docs__box--quiet"><p>'.esc(%manual->label('noKey')).'</p></section>'.lf)
if ((string)($row->error ?? void) !== void) return indentView(lf.'<section class="docs__box docs__box--quiet"><p>'.esc(%manual->label('noSummary')).esc((string)$row->error).'</p></section>'.lf)
return indentView(lf.$this->summaryText($row).lf)
view

%manual -> summaryText ($row)

line 414
<section.docs__box>
	<h2>{{ %manual->label('summary') }}</h2>
	<p.docs__lead>{{ $row->text }}</p>
</section>
view

%manual -> infoBlock

line 420
$info = static::appInfo()
if ($info === void) return indentView(lf.'<section class="docs__box"><p>'.esc(%manual->label('noInfo')).'</p></section>'.lf)
return indentView(lf.$this->infoText(static::mdBlocks(preg_replace('~^#[^\n]*\n~', void, $info))).lf)
view

%manual -> infoText (array $blocks)

line 426
<details.docs__box open>
	<summary.docs__summary>{{ %manual->label('about') }}</summary>
	<div.docs__md>
		<foreach $blocks AS $block>
			{{ %manual->mdBlock($block) }}
		</foreach>
	</div>
</details>
view

%manual -> mdBlock ($block)

line 436
<if $block->type === 'heading' && $block->depth === 2>
	<h2>{{ %manual->mdInline($block->text) }}</h2>
<elseif $block->type === 'heading' && $block->depth === 3>
	<h3>{{ %manual->mdInline($block->text) }}</h3>
<elseif $block->type === 'heading'>
	<h4>{{ %manual->mdInline($block->text) }}</h4>
<elseif $block->type === 'code'>
	<pre.docs__pre>{[ $block->text ]}</pre>
<elseif $block->type === 'quote'>
	<blockquote>{{ %manual->mdInline($block->text) }}</blockquote>
<elseif $block->type === 'hr'>
	<hr>
<elseif $block->type === 'list'>
	{{ %manual->mdList($block) }}
<elseif $block->type === 'table'>
	{{ %manual->mdTable($block) }}
<else>
	<p>{{ %manual->mdInline($block->text) }}</p>
</if>
view

%manual -> mdList ($block)

line 457
$items = $block->items
return $block->ordered ? $this->mdOrdered($items) : $this->mdBullets($items)
view

%manual -> mdBullets (array $items)

line 462
<ul>
	<foreach $items AS $item>
		{{ %manual->mdItem($item) }}
	</foreach>
</ul>
view

%manual -> mdOrdered (array $items)

line 469
<ol>
	<foreach $items AS $item>
		{{ %manual->mdItem($item) }}
	</foreach>
</ol>
view

%manual -> mdItem ($item)

line 476
<if $item->checked === true>
	<li.docs__task.docs__task--done>{{ %manual->mdBody($item) }}</li>
<elseif $item->checked === false>
	<li.docs__task>{{ %manual->mdBody($item) }}</li>
<else>
	<li>{{ %manual->mdBody($item) }}</li>
</if>
view

%manual -> mdBody ($item)

line 485
$out = static::mdInline((string)$item->text)
foreach ($item->blocks AS $block) $out .= lf.$this->mdBlock($block)
return $out
view

%manual -> mdTable ($block)

line 491
<table.docs__table>
	<thead>
		<tr>
			<foreach $block->head AS $cell>
				<th>{{ %manual->mdInline($cell) }}</th>
			</foreach>
		</tr>
	</thead>
	<tbody>
		<foreach $block->rows AS $row>
			<tr>
				<foreach $row AS $cell>
					<td>{{ %manual->mdInline($cell) }}</td>
				</foreach>
			</tr>
		</foreach>
	</tbody>
</table>
view

%manual -> filesBlock

line 511
$groups = static::sources()
if (!$groups) return void
return indentView(lf.$this->filesTable($groups).lf)
view

%manual -> filesTable (array $groups)

line 517
<details.docs__box>
	<summary.docs__summary>{{ %manual->label('files') }} ({{ array_sum(array_map('count', $groups)) }})</summary>
	<foreach $groups AS $label => $files>
		<h3>{{ $label }} <span.docs__num>{{ count($files) }}</span></h3>
		<foreach $files AS $file>
			{{ %manual->fileNodes($file) }}
		</foreach>
	</foreach>
</details>
view

%manual -> fileNodes ($file)

line 528
<details.docs__file>
	<summary.docs__summary>
		<span.docs__mono>{{ $file->name }}</span>
		<span.docs__filesum>{{ $file->summary ?: void }}</span>
		<span.docs__num>{{ count($file->nodes) }}</span>
	</summary>
	<if $file->advice !== void>
		<p.docs__meta>{{ $file->advice }}</p>
	</if>
	<if !$file->nodes>
		<p.docs__meta>{{ %manual->label('noNodes') }}</p>
	</if>
	<foreach $file->nodes AS $node>
		<div.docs__node>
			<span.docs__kind>{{ $node->type }}</span>
			<span.docs__mono>{{ $node->name }}{( $node->args !== void ? '('.$node->args.')' : void )}{( $node->ret !== void ? ': '.$node->ret : void )}</span>
			<if $node->summary !== void>
				<p.docs__meta>{{ mb_substr($node->summary, 0, 240) }}</p>
			</if>
		</div>
	</foreach>
</details>
view

%manual -> routesBlock

line 552
$groups = static::routeGroups()
if (!$groups) return void
return indentView(lf.$this->routesTable($groups).lf)
view

%manual -> routesTable (array $groups)

line 558
<details.docs__box>
	<summary.docs__summary>{{ %manual->label('routes') }} ({{ array_sum(array_map('count', $groups)) }})</summary>
	<foreach $groups AS $file => $routes>
		<h3>{{ $file }} <span.docs__num>{{ count($routes) }}</span></h3>
		<foreach $routes AS $route>
			{{ %manual->routeRow($route) }}
		</foreach>
	</foreach>
</details>
view

%manual -> routeRow (array $route)

line 569
$whole = (string)($route['route'] ?? void)
$space = strpos($whole, space)
$method = $space === false ? void : substr($whole, 0, $space)
$path = $space === false ? $whole : substr($whole, $space + 1)
return indentView(lf.$this->routeLine($method, $path, (string)($route['summary'] ?? void)).lf)
view

%manual -> routeLine (string $method, string $path, string $summary)

line 577
<div.docs__route>
	<span.docs__origin>{{ $method ?: 'GET' }}</span>
	<span.docs__path>/{{ str_replace(space, slash, trim($path)) }}</span>
	<if $summary !== void>
		<p.docs__meta>{{ $summary }}</p>
	</if>
</div>
view

%manual -> commitsBlock

line 586
$commits = static::commitList()
if (!$commits) return void
return indentView(lf.$this->commitsList($commits).lf)
view

%manual -> commitsList (array $commits)

line 592
<details.docs__box>
	<summary.docs__summary>{{ %manual->label('changes') }} ({{ count($commits) }})</summary>
	<foreach $commits AS $commit>
		<article.docs__commit>
			<h4>{{ $commit->subject }}</h4>
			<p.docs__meta>
				<span.docs__origin>{{ $commit->origin }}</span>
				{{ $commit->date }} &middot; <span.docs__mono>{{ $commit->hash }}</span>
			</p>
			<if $commit->body !== void>
				<details.docs__more>
					<summary.docs__summary>{{ %manual->label('why') }}</summary>
					<pre.docs__pre>{{ $commit->body }}</pre>
				</details>
			</if>
		</article>
	</foreach>
</details>
view

style

line 612
.docs {
	max-width: 54rem
	margin: 0 auto
	padding: 2rem 1.2rem 4rem
	font: 16px/1.6 system-ui, sans-serif
	color: #1d2126
}
.docs__head {
	margin-bottom: 1.4rem
}
.docs h1 {
	margin: 0 0 .3rem
	font-size: 1.5rem
}
.docs__sub {
	margin: 0 0 .9rem
	color: #68727d
	font-size: .92rem
}
.docs__box {
	margin: 0 0 1rem
	padding: 1rem 1.1rem
	border: 1px solid #e2e6ea
	border-radius: 10px
	background: #fff
}
.docs__box--quiet {
	background: #f6f8f9
	color: #68727d
	font-size: .92rem
}
.docs__box h2 {
	margin: 0 0 .5rem
	font-size: 1.05rem
}
.docs__summary {
	cursor: pointer
	font-weight: 600
	font-size: .98rem
	display: flex
	align-items: baseline
	gap: .5rem
	list-style: none
	\::-webkit-details-marker: display: none
}
.docs__summary::before {
	content: '\25b8'
	color: #9aa4ae
	font-weight: 400
	transition: transform .12s ease
}
.docs__file[open] > .docs__summary::before {
	transform: rotate(90deg)
}
.docs__lead {
	margin: 0 0 .8rem
}
.docs__md h2 {
	margin: 1.1rem 0 .4rem
	font-size: 1rem
}
.docs__md p, .docs__md ul {
	margin: 0 0 .7rem
}
.docs__task {
	list-style: none
	margin-left: -1.1rem
}
.docs__task::before {
	content: '\25cb\a0'
	color: #68727d
}
.docs__task--done::before {
	content: '\25cf\a0'
	color: #1d2126
}
.docs__table {
	width: 100%
	border-collapse: collapse
	font-size: .9rem
	margin: .4rem 0 1rem
}
.docs__table th, .docs__table td {
	padding: .28rem .5rem .28rem 0
	border-bottom: 1px solid #eef1f3
	vertical-align: top
}
.docs__table th {
	text-align: left
	color: #68727d
}
.docs__mono {
	font-family: ui-monospace, monospace
	font-size: .86rem
	white-space: nowrap
}
.docs__num {
	text-align: right
	color: #68727d
}
.docs h3, .docs__md h4 {
	margin: 1rem 0 .2rem
	font-size: .9rem
	color: #68727d
}
.docs__file {
	margin: .15rem 0
	padding: .3rem .5rem
	border: 1px solid #eef1f3
	border-radius: 8px
}
.docs__filesum {
	color: #68727d
	font-size: .86rem
	font-weight: 400
	margin-left: auto
	text-align: right
}
.docs__node {
	padding: .3rem 0 .3rem .8rem
	border-left: 2px solid #eef1f3
	margin: .3rem 0
}
.docs__kind {
	display: inline-block
	min-width: 4rem
	color: #68727d
	font-size: .78rem
	text-transform: uppercase
}
.docs__origin {
	display: inline-block
	margin-right: .4rem
	padding: .05rem .4rem
	border-radius: 4px
	background: #eef1f3
	color: #4a5560
	font-size: .74rem
	text-transform: uppercase
	letter-spacing: .02em
}
.docs__route {
	padding: .3rem 0
	border-bottom: 1px solid #eef1f3
}
.docs__path {
	font-family: ui-monospace, monospace
	font-size: .86rem
	word-break: break-word
}
.docs__commit {
	padding: .6rem 0
	border-bottom: 1px solid #eef1f3
}
.docs__commit h4 {
	margin: 0 0 .15rem
	font-size: .95rem
}
.docs__meta {
	margin: 0
	color: #68727d
	font-size: .84rem
}
.docs__pre {
	margin: .4rem 0 0
	padding: .6rem .7rem
	border-radius: 8px
	background: #f6f8f9
	font-family: ui-monospace, monospace
	font-size: .84rem
	white-space: pre-wrap
}
.docs__more {
	margin-top: .3rem
}
.docs__btn {
	padding: .35rem .8rem
	border: 1px solid #d6dade
	border-radius: 6px
	background: #fff
	font-size: .88rem
	cursor: pointer
}
object

%payload

/phlo/resources/payload.phlo

POST, PUT, PATCH, QUERY and file-upload data object

Everything a request carried in its body, whichever way it was sent: JSON, a form, or multipart with files, including PUT and PATCH, which PHP itself leaves for you to unpack. An uploaded file arrives as a file resource rather than a temp path. It is raw input from outside, so read it, never trust it: what you save belongs behind field validation.

payloadrequestuploadpostputpatchquery
method

%payload -> controller

line 11
contentType = %req->contentType
if (in_array(phlo('req')->method, ['POST', 'PUT', 'PATCH', 'QUERY']) && str_starts_with($contentType, 'application/json')){
$data = json_decode((string)file_get_contents('php://input'))
return $this->objData = is_object($data) ? get_object_vars($data) : (is_array($data) ? $data : [])
}
if ($_POST) loop($_POST, fn($value, $key) => $this->$key = $value)
elseif (in_array(phlo('req')->method, ['PUT', 'PATCH', 'QUERY']) && str_starts_with($contentType, 'application/x-www-form-urlencoded')){
$body = file_get_contents('php://input')
$data = []
parse_str($body, $data)
if ($data) loop($data, fn($value, $key) => $this->$key = $value)
}
elseif (in_array(phlo('req')->method, ['PUT', 'PATCH', 'QUERY']) && 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) loop($_FILES, fn($f, $key) => $this->$key = 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

%seo

/phlo/resources/seo.phlo

Multilingual SEO: sitemap.xml, robots.txt, hreflang + head meta (description/OG/Twitter/canonical)

Serves sitemap.xml and robots.txt itself and writes the head meta, so a page needs no SEO markup of its own. It reads from the app: pages for the sitemap, slugs for translated URLs, description, image and structuredData for the head, so filling those is the whole job. Every language of a page points at the others with hreflang, and a lastmod resource, when there is one, dates the sitemap.

seositemaprobotshreflangopengraphmultilingual
route

route GET sitemap.xml

line 11
This route generates a sitemap for SEO purposes when accessed via a GET request.
output($this)
route

route GET robots.txt

line 13
Defines a route that responds to GET requests by outputting the content of the robots file in plain text format with UTF-8 charset.
output($this->robots(), type: 'text/plain; charset=utf-8')
method

%seo -> robots:string

line 15
Generates a robots.txt file content based on the application's settings, specifying which user agents are allowed or disallowed from accessing certain paths, and includes a link to the sitemap.
if (!(defined('indexable') && indexable)) return 'User-agent: *'.lf.'Disallow: /'.lf
$lines = ['User-agent: *', 'Allow: /']
foreach ((array)(%app->robotsDisallow ?? []) AS $path) $lines[] = 'Disallow: '.$path
$lines[] = 'Sitemap: '.%req->base.slash.'sitemap.xml'
return implode(lf, $lines).lf
method

%seo -> intl ($uri):string

line 23
Retrieves the internationalized version of a URI from the application's slugs, returning the original URI if no translation is found.
(%app->slugs ?? [])[$uri] ?? $uri
method

%seo -> uri ($page):string

line 28
A sitemap entry is a uri string, or an object carrying that uri plus more.
is_string($page) ? $page : (string)($this->field($page, 'uri') ?? void)
method

%seo -> field ($page, $key):mixed

line 30
if (is_string($page)) return null
return is_array($page) ? ($page[$key] ?? null) : ($page->$key ?? null)
method

%seo -> lastmod ($page):?string

line 40
The date for one entry, or nothing at all when it does not parse.
$mod = $this->field($page, 'lastmod')
($mod === null || $mod === void) && class_exists('lastmod') && $mod = %lastmod->for($this->uri($page))
if ($mod === null || $mod === void) return null
if (is_string($mod) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $mod)) return $mod
$ts = is_int($mod) ? $mod : strtotime((string)$mod)
return $ts ? date('c', $ts) : null
method

%seo -> locale:string

line 49
This function retrieves the locale code corresponding to the application's language setting, defaulting to 'en' if not set.
$lang = %app->lang ?? 'en'
$map = ['nl' => 'nl_NL', 'en' => 'en_US', 'de' => 'de_DE', 'fr' => 'fr_FR', 'es' => 'es_ES', 'it' => 'it_IT', 'pt' => 'pt_PT', 'pl' => 'pl_PL', 'ru' => 'ru_RU', 'el' => 'el_GR', 'tr' => 'tr_TR', 'zh' => 'zh_CN']
return $map[$lang] ?? $lang
prop

%seo -> ogTitle:string

line 55
This retrieves the Open Graph title for SEO purposes using the title() function.
title()
prop

%seo -> ogDescr

line 56
Retrieves the description of the application, returning void if not set.
%app->description ?? void
prop

%seo -> ogImageFile:string

line 57
(string)(%app->image ?? (is_file(www.'icon.webp') ? 'icon.webp' : void))
prop

%seo -> ogImage:string

line 58
$this->ogImageFile === void ? void : %req->base.slash.ltrim($this->ogImageFile, slash)
prop

%seo -> canonical:string

line 59
This retrieves the canonical URL for SEO purposes from the request object.
%req->url
prop

%seo -> ogType:string

line 60
Sets the Open Graph type for SEO purposes, specifically to 'website'.
'website'
prop

%seo -> sitemapPages:array

line 61
(array)(%app->pages ?? [''])
prop

%seo -> sitemapLangs:array

line 62
array_keys((array)(%app->langs ?? []))
prop

%seo -> siteName:string

line 63
This expression assigns the value of the app's title or the id to the variable $siteName for SEO purposes.
%app->title ?? id
prop

%seo -> twitterCard:bool

line 64
The seo->$twitterCard property is used to configure the Twitter Card metadata for a view, enabling better integration with Twitter sharing features.
false
prop

%seo -> structuredData

line 69
Schema.org data for this page, as '@type' plus what only your app knows.
%app->structuredData ?? null
method

%seo -> schemaData:?array

line 71
if (!$this->structuredData) return null
$base = [
	'@context' => 'https://schema.org',
	'url' => $this->canonical,
	'inLanguage' => %app->lang ?? 'en',
	'mainEntityOfPage' => ['@type' => 'WebPage', '@id' => $this->canonical],
	'publisher' => ['@type' => 'Organization', 'name' => $this->siteName, 'url' => %req->base],
]
$this->ogDescr && $base['description'] = $this->ogDescr
$this->ogImage && $base['image'] = $this->ogImage
return array_replace($base, (array)$this->structuredData)
prop

%seo -> noIndex:bool

line 84
The seo->$noIndex expression evaluates to true if both %app->noLink and %app->noIndex are not set, otherwise it returns false.
%app->noLink ?? %app->noIndex ?? false
view

%seo -> view

line 86
<?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 $this->sitemapPages AS $page>
		{{ $this->page($page) }}
	</foreach>
</urlset>
view

%seo -> page ($page)

line 94
<url>
	<loc>%req->base{( ($uri = $this->uri($page)) ?: slash )}</loc>
	<if $stamp = $this->lastmod($page)>
		<lastmod>$stamp</lastmod>
	</if>
	<foreach $this->sitemapLangs 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 108
Generates an alternate link element for SEO purposes, specifying the language and URL for the current view.
<xhtml:link rel=alternate hreflang="$lang" href="%req->base$uri"{{ slash }}>
view
line 109
Generates an alternate link tag for SEO purposes, specifying the language and URL of the resource.
<link rel=alternate hreflang="$lang" href="%req->base$uri">
view

%seo -> head

line 111
<if $this->noIndex>
	<meta name=robots content=noindex,follow>
</if>
<if $this->ogDescr>
	<meta name=description content="$this->ogDescr">
</if>
<meta property=og:site_name content="$this->siteName">
<meta property=og:title content="{[ $this->ogTitle ]}">
<meta property=og:description content="$this->ogDescr">
<meta property=og:type content="$this->ogType">
<meta property=og:url content="{[ $this->canonical ]}">
<if $this->ogImage>
	<meta property=og:image content="$this->ogImage">
</if>
<meta property=og:locale content="$this->locale">
<if $this->twitterCard>
	<meta name=twitter:card content=summary_large_image>
	<meta name=twitter:title content="{[ $this->ogTitle ]}">
	<meta name=twitter:description content="$this->ogDescr">
	<if $this->ogImage>
		<meta name=twitter:image content="$this->ogImage">
	</if>
</if>
<if $data = $this->schemaData>
	<script type="application/ld+json">{{ json_encode($data, JSON_UNESCAPED_UNICODE) }}</script>
</if>
<if !$this->noIndex>
	<link rel=canonical href="{[ $this->canonical ]}">
</if>
object

%session

/phlo/resources/session.phlo

Session data object

The session as an object: read a key, write a key, done. Call objRegenerateId() the moment someone logs in or changes role, so a token from before cannot be reused. Sessions are per browser, so anything that must survive a device belongs in a model. Override options to steer the cookie: a flow that returns to you with a cross-site POST, such as an OIDC provider posting its callback, needs cookie_samesite None with cookie_secure true, because a Lax cookie is not sent on that request and the state you stored is then unreadable.

sessionwebstate
static

session :: options:array

line 10
[]
method

%session -> controller

line 12
ession_start(static::options())
$this->objData = $_SESSION
method

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

line 15
Sets a session variable with the specified key to the given value.
$_SESSION[$key] = $this->objData[$key] = $value
method

%session -> __unset ($key)

line 16
Removes the specified key from the session data and the internal object data.
unset($this->objData[$key], $_SESSION[$key])
method

%session -> __isset ($key):bool

line 17
Checks if a session variable identified by the given key is set and not null.
isset($this->objData[$key])
method

%session -> objRegenerateId ($deleteOld = true):void

line 19
Regenerates the session ID for the current session, optionally deleting the old session data based on the $deleteOld parameter.
session_regenerate_id($deleteOld)
$this->objData = $_SESSION
object

%stream

/phlo/resources/stream.phlo

Raw data stream beside the JSON command channel: stream() emits text or binary chunks under any content type, app.stream() consumes them via fetch dispatching on the response type

The channel beside the command stream, for what is not JSON: an image, a PDF, a zip, a file to download. Give it a content type, and a name when it should arrive as a download rather than be shown. On the page app.stream() picks its handling from the type that came back, so a caller does not have to know in advance what it is getting.

streambinaryrawdatadownload
function

function stream ($data = null, string $type = 'application/octet-stream', ?string $name = null):void

line 11
$res = %res
$cli = %req->cli
!$res->streaming && $res->done && error('Output already started, invalid stream()')
if (!$res->streaming){
	$res->streaming = true
	$res->type = $type
	$res->header('Cache-Control', 'no-store')
	$res->header('X-Content-Type-Options', 'nosniff')
	$res->header('X-Accel-Buffering', 'no')
	$name === null || $res->header('Content-Disposition', 'attachment; filename="'.str_replace('"', '', $name).'"')
	$res->render()
}
if ($data === null) return
foreach (is_iterable($data) ? $data : [$data] as $part){
	print((string)$part)
	$cli || [@ob_flush(), flush()]
}
view

script

line 31
const streamFilename = disposition => {
	const extended = disposition.match(/(?:^|;)\s*filename\*\s*=\s*UTF-8'[^']*'([^;]+)/i)
	const plain = disposition.match(/(?:^|;)\s*filename\s*=\s*(?:"((?:\\.|[^"])*)"|([^;]+))/i)
	const value = extended?.[1] ?? plain?.[1] ?? plain?.[2]
	if (!value) return null
	const name = value.trim().replace(/\\(.)/g, '$1')
	try { return decodeURIComponent(name) }
	catch (_) { return name }
}

app.stream = async (path, onData = null, async = false, type = null, data = null) => {
	const url = `${location.origin}/${path}`
	const headers = {}
	const csrf = obj('meta[name="csrf"]')?.content
	csrf && (headers['X-CSRF-Token'] = csrf)
	async && (headers['X-Requested-With'] = 'phlo')
	let body = data
	if (body !== null && !(body instanceof FormData) && !(body instanceof Blob)) [body = JSON.stringify(body), headers['Content-Type'] = 'application/json']
	phlo.log(`⇣ APP.STREAM ${url}`)
	const res = await fetch(url, {method: body === null ? 'GET' : 'POST', credentials: 'same-origin', headers, body})
	if (!res.ok) throw new Error(`stream ${url} 🔴 ${res.status}`)
	const mime = (res.headers.get('content-type') ?? '').split(';')[0].trim()
	const kind = type ?? (mime === 'application/x-ndjson' ? 'ndjson' : mime === 'application/json' ? 'json' : mime === 'text/event-stream' ? 'sse' : mime.startsWith('text/') ? 'text' : 'raw')
	const emit = value => onData && onData(value)
	if (kind === 'json'){
		const value = await res.json()
		emit(value)
		return value
	}
	if (kind === 'blob'){
		const blob = await res.blob()
		const name = streamFilename(res.headers.get('content-disposition') ?? '')
		const value = name ? new File([blob], name) : blob
		emit(value)
		return value
	}
	const reader = res.body.getReader()
	const decoder = new TextDecoder()
	const out = []
	const parts = []
	let text = ''
	let buffer = ''
	const line = raw => {
		if (!raw) return
		const value = JSON.parse(raw)
		out.push(value)
		emit(value)
	}
	const frame = raw => {
		if (!raw.trim()) return
		let event = 'message'
		const data = []
		raw.split('\n').forEach(l => l.startsWith('event:') ? event = l.slice(6).trim() : l.startsWith('data:') && data.push(l.slice(5).replace(/^ /, '')))
		const value = {event, data: data.join('\n')}
		out.push(value)
		emit(value)
	}
	while (true){
		const {done, value} = await reader.read()
		if (done) break
		if (kind === 'raw'){
			parts.push(value)
			emit(value)
			continue
		}
		const piece = decoder.decode(value, {stream: true})
		if (kind === 'text'){
			text += piece
			emit(piece)
			continue
		}
		buffer += piece
		if (kind === 'sse'){
			const hold = buffer.endsWith('\r') ? '\r' : ''
			buffer = buffer.slice(0, buffer.length - hold.length).replace(/\r\n?/g, '\n') + hold
		}
		const pieces = buffer.split(kind === 'sse' ? '\n\n' : '\n')
		buffer = pieces.pop()
		pieces.forEach(kind === 'sse' ? frame : line)
	}
	if (kind === 'raw') return new Blob(parts, {type: mime || 'application/octet-stream'})
	const tail = decoder.decode()
	if (kind === 'text'){
		tail && [text += tail, emit(tail)]
		return text
	}
	buffer += tail
	buffer && (kind === 'sse' ? frame(buffer.replace(/\r\n?/g, '\n')) : line(buffer))
	return out
}
object

%tasks

/phlo/resources/tasks.phlo

Cron runner for %app->tasks. One cron entry per app triggers this every minute.

Everything scheduled in one place: %app->tasks holds what runs and when, and one cron entry per app triggers this every minute. A task takes a lock, so a run that lasts longer than its interval does not start over itself. Timing is per minute, so anything finer belongs in a daemon rather than here.

cronscheduletasksscheduler
static

tasks :: dir:string

line 10
Accesses the directory path for tasks, specifically pointing to 'tasks/'.
data.'tasks/'
static

tasks :: run:void

line 12
Executes scheduled tasks by checking their due status and locking them to prevent concurrent execution. It saves the run details and marks the task as completed after execution.
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 27
Saves the run data to a JSON file in the specified directory, using the provided name, do, schedule, and return values.
json_write(static::dir().$name.'.json', arr(do: $do, schedule: $schedule, return: $return))
static

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

line 33
daily and weekly match the exact minute the task names.
$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, $now))) return false
	return $last < strtotime('monday this week', $now)
}
return false
static

tasks :: fire ($do)

line 51
Executes a task defined by a Closure, a 'Class::method' string, or a resource-name string, returning the result of the execution.
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):int

line 61
Retrieves the last run timestamp of a task from a file, returning 0 if the file does not exist.
$file = static::dir().$name.'.last'
return is_file($file) ? (int)file_get_contents($file) : 0
static

tasks :: markRun ($name, $ts):int|false

line 66
Writes the timestamp of the last run of a task to a file named after the task in the specified directory, using exclusive locking to prevent concurrent writes.
file_put_contents(static::dir().$name.'.last', (string)$ts, LOCK_EX)
static

tasks :: lock ($name):bool

line 68
Creates a lock file for a task if it does not already exist or is older than one hour.
$file = static::dir().$name.'.lock'
if (is_file($file) && (time() - filemtime($file)) < 3600) return false
touch($file)
return true
static

tasks :: unlock ($name):bool

line 75
Removes the lock file associated with a task, allowing it to be executed again.
@unlink(static::dir().$name.'.lock')
object

%useragent

/phlo/resources/useragent.phlo

User agent information

Reads operating system, browser and device out of the user agent string. That string is a claim, not a fact: it can be turned off, faked or shortened, so use it for statistics and never as a condition for something that matters. What a browser can do is worth asking the browser itself.

useragentbrowserosdeviceweb
prop

%useragent -> source:?string

line 10
This expression retrieves the user agent string from the request object, returning null if it is not set.
%req->userAgent ?: null
prop

%useragent -> os:string

line 12
Determines the operating system from the user agent string by matching it against predefined patterns.
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:string

line 28
Extracts the operating system version from the user agent string if available, returning it in a cleaned format.
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:string

line 39
Returns the full operating system name along with its version if available; otherwise, it returns 'Unknown' or just the OS name.
if (!$this->OS) return 'Unknown'
if ($this->OS === 'Windows') return 'Windows'
$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.space.$short)
prop

%useragent -> name:string

line 49
Determines the name of the web browser based on the user agent string provided in the source. It checks for various patterns to identify popular browsers like Chrome, Firefox, and Safari, returning 'Unknown' if no match is found.
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:string

line 66
Extracts the version number from the user agent string if it matches specific browser patterns, returning the cleaned version or void if no match is found.
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:string

line 77
Returns the full user agent string, including the name and version of the user agent, formatted to exclude unnecessary parts.
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.space.$short)
prop

%useragent -> device:string

line 86
Determines the type of device (Tablet, Phone, or Desktop) based on the user agent string stored in the source property.
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

Visitor tracking via heartbeat

Counts visitors from the page itself with a heartbeat, so time on page and who is online now are real rather than guessed from page loads. It keeps a token instead of an IP for recognition, and bots are filtered out before anything is written. A heartbeat every few seconds is a write per visitor, so watch the table on a busy site and prune it.

visitorsanalyticsheartbeattracking
static

visitors :: table:string

line 12
The 'visitors::$table' refers to the database table associated with the 'visitors' resource in Phlo.
'visitors'
static

visitors :: columns

line 13
Defines the columns for the visitors resource, specifying the attributes to be used in data operations.
'id,token,host,page,lang,IP,browser,os,device,active_seconds,state,width,height,referrer,created,changed'
static

visitors :: history:array

line 15
Retrieves a history of visitor counts, grouping by date and counting distinct tokens and total visits.
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:int

line 16
This retrieves the count of distinct online visitors who have changed within the last 9 seconds.
static::item(columns: 'COUNT(DISTINCT token)', where: 'changed >= (UNIX_TIMESTAMP() - 9)')
static

visitors :: lastHour:int

line 17
Retrieves the count of distinct visitors (tokens) who have changed within the last hour.
static::item(columns: 'COUNT(DISTINCT token)', where: 'changed >= (UNIX_TIMESTAMP() - 3600)')
static

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

line 19
Determines if the user agent string indicates that the visitor is a bot by matching it against a predefined regex pattern.
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 24
Parses the referrer URL to identify the search engine used, returning a formatted string indicating the search engine or the host if no match is found.
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\./', void, (string)(parse_url($url, PHP_URL_HOST) ?? void)))
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,s,pp,ps

line 31
Handles the PUT request for updating heartbeat information of visitors, including consent, session duration, and page interactions, while storing the data in the database.
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.%cookies->token.space.%useragent->source) : token(20, $n.space.date('Ymd').space.%cookies->token.space.%req->ip)
$delta = max(0, min(120, (int)%payload->s))
$prevDelta = max(0, min(120, (int)%payload->ps))
$lang = strlen(%payload->l) === 2 ? %payload->l : %app->lang ?? 'en'
$referrer = ($r = (string)%payload->r) && !str_contains($r, host) ? static::parseReferrer($r) : null
static::DB()->query('INSERT INTO '.static::$table.' (id, token, host, page, lang, IP, browser, os, device, active_seconds, state, width, height, referrer, created, changed) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE token = VALUES(token), host = VALUES(host), page = VALUES(page), lang = VALUES(lang), IP = VALUES(IP), browser = VALUES(browser), os = VALUES(os), device = VALUES(device), active_seconds = active_seconds + VALUES(active_seconds), state = VALUES(state), width = VALUES(width), height = VALUES(height), referrer = IF(referrer IS NULL OR referrer = '.sq.sq.', VALUES(referrer), referrer), changed = VALUES(changed)', $id, token(20, (string)(%cookies->token ?? %payload->n)), host, %payload->u, $lang, $consent ? %req->ip : void, $consent ? %useragent->full.(%payload->a ? ' App' : void) : substr(md5((string)%useragent->source), 0, 8), $consent ? %useragent->osFull : void, $consent ? %useragent->device : void, $delta + $prevDelta, %payload->v, %payload->w, %payload->h, $referrer, time(), time())
$pages = preg_replace('/visitors$/', 'visitor_pages', (string)static::$table)
if (strlen((string)%payload->p) >= 8){
	$pv = token(20, $id.space.%payload->p)
	static::DB()->query('INSERT INTO '.$pages.' (id, visitor, host, page, lang, active_seconds, beats, created, changed) VALUES (?,?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE active_seconds = active_seconds + VALUES(active_seconds), beats = beats + 1, changed = VALUES(changed)', $pv, $id, host, %payload->u, $lang, $delta, 1, time(), time())
}
if (strlen((string)%payload->pp) >= 8){
	$ppv = token(20, $id.space.%payload->pp)
	static::DB()->query('UPDATE '.$pages.' SET active_seconds = active_seconds + ?, beats = beats + 1, changed = ? WHERE id = ?', $prevDelta, time(), $ppv)
}
view

script

line 52
Tracks user activity on the page by measuring active time and sending heartbeat data to the server, including user state and visibility status.
let curpath = app.path
let pv = phlo.token(12)
let hbTimer, activeMs = 0, activeAt = null, prev = null
const visible = () => document.visibilityState === 'visible'
const accrue = () => {
	if (activeAt === null) return
	activeMs += performance.now() - activeAt
	activeAt = visible() ? performance.now() : null
}
const flush = beacon => {
	accrue()
	const consent = document.cookie.includes('cookieChoice=all')
	window.name ||= phlo.token(8)
	const s = Math.floor(activeMs / 1000)
	activeMs -= s * 1000
	const 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: pv, r: (r = document.referrer) ? (r === `${location.origin}/` ? null : r) : null, c: consent ? 1 : 0, s, pp: prev ? prev.p : null, ps: prev ? prev.s : null})
	prev = null
	fetch('/heartbeat', {method: 'PUT', headers: {'Content-Type': 'application/json'}, body, keepalive: !!beacon})
}
const heartbeat = () => delay('heartbeat', 333, () => {
	clearTimeout(hbTimer)
	flush()
	if (visible()) hbTimer = setTimeout(heartbeat, 20000)
})
document.addEventListener('visibilitychange', () => {
	if (!visible()) return flush(true)
	activeAt = performance.now()
	heartbeat()
})
addEventListener('resize', heartbeat)
addEventListener('pagehide', () => flush(true))
app.updates.push(() => {
	if (curpath === app.path) return
	if (prev) flush()
	accrue()
	prev = {p: pv, s: Math.floor(activeMs / 1000)}
	pv = phlo.token(12)
	activeMs = 0
	curpath = app.path
	heartbeat()
})
if (visible()) activeAt = performance.now()
heartbeat()
object

%websocket

/phlo/resources/websocket.phlo

Server-side WebSocket handler via phloWS

Enable this class only when websockets are configured for the host

websocketrealtimewsserver
static

websocket :: connect ($wsHost, $wsToken, $wsSocket):bool

line 10
Establishes a WebSocket connection using the specified host, token, and socket parameters.
!function_exists('wsConnect') || wsConnect($wsHost, $wsToken, $wsSocket)
static

websocket :: auth ($wsHost, $wsToken, $wsSocket):bool

line 11
Checks if the 'wsAuth' function exists and, if not, calls it with the provided parameters for WebSocket authentication.
!function_exists('wsAuth') || wsAuth($wsHost, $wsToken, $wsSocket)
static

websocket :: receive ($wsHost, $wsToken, $wsSocket, $data):bool

line 12
Receives data from a WebSocket connection using the specified host, token, and socket, processing the incoming data as JSON.
function_exists('wsReceive') && wsReceive($wsHost, $wsToken, $wsSocket, ...json_decode($data, true))
static

websocket :: close ($wsHost, $wsToken, $wsSocket):bool

line 13
Closes a WebSocket connection using the specified host, token, and socket.
function_exists('wsClose') && wsClose($wsHost, $wsToken, $wsSocket)
object

%WhatsApp

/phlo/resources/WhatsApp.phlo

WhatsApp client for phloWA using whatsapp-web.js

Speaks to a phloWA instance, which holds the actual WhatsApp session, so this resource is a client and not a connection: without that service running, nothing is sent. A contact is a full WhatsApp address, and one ending in @g is a group, which number() and isGroup() sort out. It is an unofficial route, so treat volume and content with the care an account you cannot replace deserves.

whatsappmessagingapi
method

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

line 11
Initializes a WhatsApp instance with a specified URL and secret, ensuring the URL ends with a slash.
$this->url = rtrim($url, slash).slash
static

WhatsApp :: channel ($channel):static

line 13
Creates a new instance of the WhatsApp channel using the provided URL and secret, defaulting to 'http://localhost:8081' and 'void' if not specified.
new static($channel->configData->url ?? 'http://localhost:8081', $channel->secretData->secret ?? void)
method

%WhatsApp -> number ($contact):string

line 15
Extracts the phone number from a WhatsApp contact string, returning an error if the format is invalid.
($pos = strpos($contact, '@')) ? substr($contact, 0, $pos) : error('Invalid contact: '.esc($contact))
method

%WhatsApp -> isGroup ($contact):bool

line 16
Checks if the specified contact is a WhatsApp group by verifying if the contact contains a '@g' suffix.
last($this->number($contact), (bool)strpos($contact, '@g'))
method

%WhatsApp -> status:obj

line 18
Retrieves the current status from WhatsApp using a GET request.
$this->request('status', GET: true)
method

%WhatsApp -> health:obj

line 19
Checks the health status of the WhatsApp service by sending a GET request.
$this->request('health', GET: true)
method

%WhatsApp -> qr:obj

line 20
Sends a request to retrieve the QR code for WhatsApp authentication.
$this->request('qr', GET: true)
method

%WhatsApp -> disconnect:obj

line 21
Disconnects the current WhatsApp session by sending a disconnect request.
$this->request('disconnect')
method

%WhatsApp -> read ($chat):obj

line 23
Sends a request to read messages from a specified WhatsApp chat.
$this->request('read', chat: $chat)
method

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

line 24
Sends a reaction emoji to a specified message in WhatsApp.
$this->request('reaction', msg: $msg, emoji: $emoji)
method

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

line 26
Sends a text message to the specified recipient using WhatsApp.
$this->request('text', to: $to, text: $text)
method

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

line 27
Sends an image message via WhatsApp to the specified recipient, optionally including a text message.
$this->request('image', to: $to, filename: $file->name, image: $file->src, text: $text)
method

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

line 28
Sends a location message via WhatsApp to the specified recipient with latitude, longitude, and optional text.
$this->request('location', to: $to, lat: $lat, lon: $lon, text: $text)
method

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

line 29
Sends a document via WhatsApp to the specified recipient, including an optional text message.
$this->request('document', to: $to, filename: $file->name, document: $file->src, text: $text)
method

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

line 31
Sends an audio message to a specified recipient using the provided audio file.
$this->request('audio', to: $to, audio: $file->src)
method

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

line 32
Sends a voice message to a specified recipient using the provided audio file.
$this->request('voice', to: $to, audio: $file->src)
method

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

line 34
Sends a poll message to a specified WhatsApp recipient with given options, allowing for multiple selections if specified.
$this->request('poll', to: $to, name: $name, options: $options, multi: $multi)
method

%WhatsApp -> startTyping ($to):obj

line 36
Starts the typing indicator for a specified recipient in WhatsApp.
$this->request('typing/start', to: $to)
method

%WhatsApp -> stopTyping ($to):obj

line 37
Stops the typing indicator for a specific recipient in WhatsApp.
$this->request('typing/stop', to: $to)
method

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

line 39
Sends a request to the WhatsApp API with the specified action and data, returning a response object indicating success or failure.
$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):string

/phlo/resources/active.phlo line 9

Build active class attribute for UI state

Writes the whole class attribute, or nothing at all when there is nothing to say, so a link needs no ternary in the view. Because it emits the attribute rather than a class name, it goes into the tag itself and not inside another class attribute.

activeclassuiviewhtml
Generates a class attribute string for HTML elements, adding 'active' to the specified class list if the condition is true.
$cond || $classList ? ' class="'.$classList.($cond ? ($classList ? space : void).'active' : void).'"' : void
function

age(int $time):int

/phlo/resources/age.phlo line 9

Get age in seconds since a given timestamp

Seconds since a timestamp, so an expiry or a cache check reads as a comparison. Nothing more than that: use time_human for something a reader sees.

agetimetimestamp
Calculates the age by subtracting the given time from the current time.
time() - $time
function

age_human(int $age):string

/phlo/resources/age.human.phlo line 10

Convert age in seconds to human readable text

The reader-facing side of age(): give it an age in seconds and it gives a label like 3 hours. Pass an age here and a timestamp to time_human; the two are easy to swap and both give an answer, only the wrong one.

agehumantimeformat
Calculates a human-readable time duration from the given age in seconds.
time_human(time() - $age)
function

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

/phlo/resources/apcu.phlo line 14

Cache callback results in APCu

Wraps a callback in APCu with one line, and the callback only runs when the value is not there. The cache lives in one server's shared memory and is gone after a restart, and a CLI run has its own unless apc.enable_cli is on, which makes it right for something expensive and reproducible and wrong for something authoritative. Key it on everything the answer depends on, or two different questions get the same answer.

cacheapcuperformance
apcu_entry holds a lock on the key for the length of the callback.
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):array

/phlo/resources/await.phlo line 11

Run app targets in parallel, via the daemon pool or one-shot CLI processes

Runs several app targets at once and returns their results in the order you asked, so three slow calls take as long as the slowest instead of their sum. Under the daemon they run in the pool; without it each becomes a CLI process. The whole wait is bounded, five minutes unless await_timeout says otherwise, and a child still running is killed rather than waited for, so one hung job cannot hold a request.

awaitparallelcliprocessdaemon
Waits for multiple jobs to complete, handling their output and errors, and returns the results. It manages child processes and ensures that they do not block indefinitely.
	if (daemon) return daemon::await($jobs)
	$children = []
	$open = []
	foreach ($jobs AS $i => $job){
		[$cb, $args] = is_array($job) ? [$job[0], array_slice($job, 1)] : [$job, []]
		$cmd = cli.space.escapeshellarg($_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])
		stream_set_blocking($pipes[1], false)
		stream_set_blocking($pipes[2], false)
		$children[$i] = obj(proc: $proc, out: $pipes[1], err: $pipes[2], stdout: void, stderr: void)
		$open['o'.$i] = $pipes[1]
		$open['e'.$i] = $pipes[2]
	}
	// Drain every child's stdout AND stderr together: reading one stream to EOF before the
	// other deadlocks a child that fills the unread pipe. Bound the whole wait so a hung
	// child cannot block the caller forever.
	$deadline = time() + (defined('await_timeout') ? await_timeout : 300)
	while ($open){
		$read = $open
		$write = $except = []
		if (@stream_select($read, $write, $except, 1) === false) break
		foreach ($read AS $key => $stream){
			$chunk = fread($stream, 65536)
			if ($chunk === void || $chunk === false){
				feof($stream) && $open = array_diff_key($open, [$key => 1])
				continue
			}
			$i = substr($key, 1)
			if ($key[0] === 'o') $children[$i]->stdout .= $chunk
			else $children[$i]->stderr .= $chunk
		}
		if (time() >= $deadline) break
	}
	// Terminate any child still running (deadline or a stream_select error). SIGKILL cannot
	// be ignored, so proc_close below will not block on a process that drops SIGTERM.
	foreach ($children AS $child) (proc_get_status($child->proc)['running'] ?? false) && proc_terminate($child->proc, 9)
	$results = []
	foreach ($children AS $i => $child){
		fclose($child->out)
		fclose($child->err)
		$code = proc_close($child->proc)
		$err = trim($child->stderr)
		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($child->stdout, true)
		$results[$i] = json_last_error() === JSON_ERROR_NONE ? $json : $child->stdout
	}
	return $results
function

button(...$args):string

/phlo/resources/tags.form.phlo line 11

DOM form tags for button, input, select and textarea

button(), input(), select() and textarea() are tag() with the name filled in, so they take the same named arguments and the same escaping. They exist because these four appear so often that the name is noise.

formtagshtmlview
Creates a button element with the specified arguments passed as props.
tag('button', ...$args)
function

camel(string $text):string

/phlo/resources/camel.phlo line 9

Convert text to camelCase

Turns a sentence into camelCase, for making a property or key name out of a title. It splits on spaces, so a hyphen or an underscore survives into the result.

camelcasestringformat
Converts a given string to camel case by capitalizing the first letter of each word and removing spaces.
lcfirst(str_replace(space, void, ucwords(lcfirst($text))))
function

chunk(...$cmds):void

/phlo/resources/chunk.phlo line 10

Stream JSON chunks over CLI or Server-Sent Events

Sends a piece of a response now instead of at the end, so long work reports as it goes. The first call switches the response to a stream and fixes the content type, so anything printed after it is part of that stream and a normal return can no longer be made. In the browser these arrive as commands; on the CLI they are plain lines.

chunkstreamssecliasync
	$res = %res
	$cli = %req->cli
	!$res->streaming && $res->done && error('Output already started, invalid chunk()')
	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 = 'application/x-ndjson'
		$res->header('Cache-Control', 'no-store')
		$res->header('X-Content-Type-Options', 'nosniff')
		$res->render()
	}
	print(json_encode($cmds, jsonFlat).lf)
	$cli || [@ob_flush(), flush()]
function

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

/phlo/resources/create.phlo line 9

Create associative array from iterable using callbacks

Turns a list into a keyed array in one pass: the first callback makes the key, the optional second makes the value. Keys that repeat overwrite each other, so this doubles as a way to fold a list into its unique entries.

createarrayiterablecallback
Creates an associative array by using the values from the iterable as keys and the optional value callback to determine the corresponding values.
array_combine(loop($items, $keyCb), $valueCb ? loop($items, $valueCb) : $items)
function

en($text, ...$args):string

/phlo/resources/lang.phlo line 12

Language and translation resource

%lang prints the current app language through its view, so it drops straight into a link or an attribute. Beyond that it is the translation layer behind nl() and en(): a phrase is hashed, looked up in langs/<lang>.ini, and translated by the AI in the background when it is missing, so the first visitor reads the source text and the next one reads the translation. Editing a source phrase changes its hash and orphans the old translation, so a rewrite costs a re-translation.

langtranslationi18nlocaleai
This function retrieves a translation for the specified text in English, optionally formatting it with additional arguments.
%lang->translation('en', $text, ...$args)
function

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

/phlo/resources/exec.stream.phlo line 13

Stream shell command output via yielding

Yields a shell command's output line by line, with error true on the lines that came from stderr, so a failure can be shown as it happens instead of after. It reads stdout and stderr together, which is what keeps a command that writes a lot to one of them from deadlocking. Pass a timeout for anything that could hang; zero waits forever.

streamshellcliprocessyield
Reads stdout and stderr in one select loop rather than one after the other.
	$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, $QUERY = null, bool $DELETE = false, string|bool|null $agent = null, string|bool $cookies = false, int $timeout = 15, &$response = null)

/phlo/resources/HTTP.phlo line 15

HTTP request helper via cURL

One function for a whole request: a URL and headers is a GET, and POST, PUT, PATCH, QUERY or DELETE named as an argument decides the rest. Pass an array with JSON true and it is sent as JSON. The timeout is fifteen seconds and it is a real limit, so raise it deliberately for a slow API rather than by accident. Pass response by reference to see the status and the headers, which is the only way to tell an empty answer from a failure.

httpcurlrequestapi
Follows redirects on its own and raises only when the transport itself fails.
	$curl = curl_init($url)
	if ($POST !== null || $PUT !== null || $PATCH !== null || $QUERY !== 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]
		elseif (!is_null($QUERY)) [$method = 'QUERY', $content = $QUERY]
		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)
	if ($cookies !== false) [$jar = $cookies === true ? data.'cookies.txt' : $cookies, curl_setopt($curl, CURLOPT_COOKIEFILE, $jar), curl_setopt($curl, CURLOPT_COOKIEJAR, $jar)]
	$resHeaders = []
	curl_setopt_array($curl, [CURLOPT_HTTPHEADER => $headers, CURLOPT_FOLLOWLOCATION => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_TIMEOUT => $timeout, CURLOPT_ENCODING => void, CURLOPT_HEADERFUNCTION => function($ch, $line) use (&$resHeaders){
		$parts = explode(colon, $line, 2)
		count($parts) === 2 && $resHeaders[strtolower(trim($parts[0]))] = trim($parts[1])
		return strlen($line)
	}])
	$res = curl_exec($curl)
	$status = (int)curl_getinfo($curl, CURLINFO_RESPONSE_CODE)
	$response = obj(ok: $res !== false && $status >= 200 && $status < 300, status: $status, headers: $resHeaders, error: $res === false ? curl_error($curl) : null)
	if ($res === false) error('HTTP error: '.curl_error($curl))
	return $res
function

input(...$args):string

/phlo/resources/tags.form.phlo line 12

DOM form tags for button, input, select and textarea

button(), input(), select() and textarea() are tag() with the name filled in, so they take the same named arguments and the same escaping. They exist because these four appear so often that the name is noise.

formtagshtmlview
Creates an input element in the view with the specified arguments.
tag('input', ...$args)
function

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

/phlo/resources/n8n.phlo line 10

Call n8n webhook endpoint

Fires an n8n workflow from your app, with test true to hit the test URL that only listens while the editor is open. It posts and returns what came back; n8n itself decides whether that is the result or just an acknowledgement, so a long workflow is better answered by a callback than by waiting here.

n8nwebhookhttpautomation
Sends an HTTP POST request to an n8n webhook with optional data and a test flag.
HTTP(%creds->n8n->server.'webhook'.($test ? '-test' : '').'/'.$webhook, POST: $data)
function

nl($text, ...$args):string

/phlo/resources/lang.phlo line 11

Language and translation resource

%lang prints the current app language through its view, so it drops straight into a link or an attribute. Beyond that it is the translation layer behind nl() and en(): a phrase is hashed, looked up in langs/<lang>.ini, and translated by the AI in the background when it is missing, so the first visitor reads the source text and the next one reads the translation. Editing a source phrase changes its hash and orphans the old translation, so a rewrite costs a re-translation.

langtranslationi18nlocaleai
Translates the given text into Dutch using the specified arguments for formatting.
%lang->translation('nl', $text, ...$args)
function

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

/phlo/resources/notify.phlo line 9

Notification to the central hub: POST to [notify].url (secret header) via Phlo's HTTP() function. No-op without [notify] config.

One line to send a notification to the central hub, and quietly nothing at all when no hub is configured, so the same code runs on a laptop and on a server. That silence cuts both ways: a missing config gives no error, so check the hub itself when a message fails to arrive.

Sends a notification with a specified title, body, type, level, and optional user to a configured URL using HTTP.
	$cfg = %creds->notify ?? null
	if (!$cfg) return
	$url = $cfg->url ?? void
	$secret = $cfg->secret ?? void
	if ($url === void || $secret === void) return
	try {
		HTTP($url, ['secret: '.$secret], true, [
			'app' => (string)($cfg->app ?? (defined('id') ? id : 'app')),
			'server' => $cfg->server ?? 'local',
			'host' => %req->host ?? void,
			'type' => $type,
			'level' => $level,
			'title' => $title,
			'body' => $body,
			'user' => $user,
		])
	}
	catch (\Throwable $e){}
function

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

/phlo/phlo.php line 241
Creates or retrieves an instance of a Phlo object based on the provided name and arguments, managing a static list of objects for reuse.
static $list = [];
if ($phloName === 'tech/reset'){
	obj::$classProps = [];
	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 39
Initializes a Phlo application by setting up the environment, loading necessary classes and functions, and configuring various runtime parameters based on the provided arguments.
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['daemon']    ??= 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['daemon']) require_once __DIR__.'/classes/daemon.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):bool

/phlo/resources/phlo.async.phlo line 10

Run an app target in the background, via the daemon pool or a one-shot CLI process

Fire and forget: it starts an app target and returns at once, so nothing that follows can see the result or whether it went well. Use it for work a visitor should not wait on, and let the job write its own outcome somewhere you can read back. Use phlo_sync when you need the answer, await when you need several.

asynccliprocessbackgroundappdaemon
Executes a callback function asynchronously, passing any additional arguments, and returns the process ID if successful.
	if (daemon) return daemon::fire($cb, $args)
	$cmd = cli.space.escapeshellarg($_SERVER['SCRIPT_FILENAME']).space.escapeshellarg($cb).loop($args, fn($a) => space.escapeshellarg((string)$a), void).' > /dev/null 2>&1 & echo $!'
	exec($cmd, $r)
	return isset($r[0]) && ctype_digit($r[0]) && (int)$r[0] > 0
function

phlo_cli(array $args):void

/phlo/phlo.php line 191
if (!$args) return;
$target = array_shift($args);
$result = phlo_dispatch($target, $args);
if (isset($result)) print(json_encode($result, jsonFlat).lf);
function

phlo_dispatch(string $target, array $args = []):mixed

/phlo/phlo.php line 178
Dispatches a method call to an object or a static method of a class based on the provided target string, optionally passing arguments.
if (str_contains($target, dot)){
	[$object, $method] = explode(dot, $target, 2);
	$handle = phlo($object);
	return $args ? $handle->$method(...$args) : ($handle->hasMethod($method) ? $handle->$method() : $handle->$method);
}
if (str_contains($target, '::')){
	[$class, $method] = explode('::', $target, 2);
	return $class::$method(...$args);
}
return $target(...$args);
function

phlo_exception(Throwable $e):void

/phlo/phlo.php line 34
Handles exceptions by passing the Throwable object to the phlo_error_handle function for processing.
require_once engine.'error.php';
phlo_error_handle($e);
function

phlo_exists(string $obj):bool

/phlo/resources/phlo.exists.phlo line 9

Check if compiled Phlo class exists

Asks whether a Phlo object was built, by looking for the generated PHP rather than by loading anything, so it costs nothing and triggers no autoload. Use it to make a resource optional: check first, then call. This is a build question, not a runtime one, so a resource added after the last build answers no until it is rebuilt.

phloexistsclassbuildruntime
Checks if a specified PHP file exists in the given object path.
is_file(php.strtr($obj, [us => dot]).'.php')
function

phlo_load(bool $http):void

/phlo/phlo.php line 154
Loads the necessary runtime files for the application, ensuring that the application is only loaded once and that the correct content type is set for HTTP responses.
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_serve():void

/phlo/phlo.php line 198
ini_set('display_errors', 'stderr');
stream_set_blocking(STDIN, true);
fwrite(STDOUT, json_encode(['t' => 'ready']).lf);
while (($line = fgets(STDIN)) !== false){
	$line = trim($line);
	if ($line === void) continue;
	$msg    = json_decode($line, true) ?: [];
	$id     = $msg['id']     ?? null;
	$target = (string)($msg['target'] ?? void);
	$args   = (array)($msg['args'] ?? []);
	$stream = (bool)($msg['stream'] ?? false);
	$lineBuf = void;
	$emit = static function(string $chunk) use (&$lineBuf, $id):string {
		$lineBuf .= $chunk;
		while (($pos = strpos($lineBuf, lf)) !== false){
			$out = substr($lineBuf, 0, $pos);
			$lineBuf = substr($lineBuf, $pos + 1);
			fwrite(STDOUT, json_encode(['id' => $id, 't' => 'line', 'data' => $out], jsonFlat).lf);
		}
		return void;
	};
	try {
		if ($target === void) error('No target');
		if ($stream){
			ob_start($emit, 1);
			$result = phlo_dispatch($target, $args);
			while (ob_get_level()) ob_end_flush();
			if ($lineBuf !== void) fwrite(STDOUT, json_encode(['id' => $id, 't' => 'line', 'data' => $lineBuf], jsonFlat).lf);
		}
		else $result = phlo_dispatch($target, $args);
		fwrite(STDOUT, json_encode(['id' => $id, 't' => 'done', 'result' => $result], jsonFlat).lf);
	}
	catch (Throwable $e){
		while (ob_get_level()) ob_end_clean();
		fwrite(STDOUT, json_encode(['id' => $id, 't' => 'error', 'message' => $e->getMessage()], jsonFlat).lf);
	}
	phlo('tech/reset');
	if (session_status() === PHP_SESSION_ACTIVE) session_write_close();
	gc_collect_cycles();
}
function

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

/phlo/resources/phlo.stream.phlo line 11

Stream an app target's output line by line, via the daemon pool or a one-shot CLI process

For a job whose output you want while it runs rather than at the end: it yields line by line, so a foreach can pass every line straight to chunk() and a page fills up as the work happens. The generator only advances while you read it, so a caller that stops reading stops the job.

streamphlocliprocessyielddaemon
Streams output from a callback function, allowing for asynchronous processing of data. It can operate in a daemon mode or execute a command in the CLI context.
	if (daemon){
		yield from daemon::stream($cb, $args)
		return
	}
	yield from exec_stream(cli.space.escapeshellarg($_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 10

Run an app target synchronously, via the daemon pool or a one-shot CLI process

Runs an app target and gives you its return value, in the pool under the daemon and as a CLI process without one. JSON output is decoded, anything else comes back as text, and a job that answers with an error field raises it here, so a failure surfaces in the caller rather than being swallowed.

synccliprocessappdaemon
Executes a callback function in a synchronous manner, passing any additional arguments to it, and returns the result as a JSON object or an error message if execution fails.
	if (daemon) return daemon::run($cb, $args)
	$cmd = cli.space.escapeshellarg($_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)
	if ($code !== 0) error('Could not execute "'.esc($cb).'" via CLI')
	$j = json_decode($out, true)
	if (json_last_error() !== JSON_ERROR_NONE) return $out
	if (is_array($j) && isset($j['error'])) error($j['error'])
	return $j
function

phlo_thread():void

/phlo/phlo.php line 116
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;
	}
	$isControl = build && debug && control && str_starts_with($req->path.slash, control.slash);
	if (auth && !$isControl){
		phlo_auth('site', 'Phlo App - '.host);
		if (phlo('res')->done) return;
	}
	if ($isControl){
		require_once engine.'control.php';
		phlo_control::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') return;
	phlo_exception($e);
}
catch (Throwable $e){
	phlo_exception($e);
}
function

select(...$args):string

/phlo/resources/tags.form.phlo line 13

DOM form tags for button, input, select and textarea

button(), input(), select() and textarea() are tag() with the name filled in, so they take the same named arguments and the same escaping. They exist because these four appear so often that the name is noise.

formtagshtmlview
Creates a 'select' HTML element with the provided arguments as attributes and options.
tag('select', ...$args)
function

setting(?string $key = null, $value = null):mixed

/phlo/resources/settings.phlo line 11

Persistent app settings in data/settings.json: setting() lists everything, setting(key) reads one value or null, setting(key, value) writes

Three shapes in one function: setting() gives everything, setting(key) reads one value or null, setting(key, value) writes and saves at once. It is meant for the handful of choices an app keeps, in data/settings.json, not for data with a life of its own; anything you would want to query or relate belongs in a model.

settingsconfigstoragejson
	$store = %JSON('settings', assoc: true)
	if ($key === null) return $store->objData
	if (func_num_args() > 1){
		$store->$key = $value
		$store->objChanged && $store->objWrite($store->objData)
	}
	return $store->$key
function

slug(string $text):string

/phlo/resources/slug.phlo line 9

Convert text to URL slug

Makes a URL slug: accents are folded to their ascii letter, everything else becomes a dash. Two different titles can produce the same slug, so check for a collision before you use one as an id.

slugurlstringformat
Converts a given string into a URL-friendly slug by removing non-alphanumeric characters, converting to lowercase, and replacing spaces with dashes.
trim(preg_replace('/[^a-z0-9]+/', dash, strtolower(iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $text))), dash)
function

stream($data = null, string $type = 'application/octet-stream', ?string $name = null):void

/phlo/resources/stream.phlo line 11

Raw data stream beside the JSON command channel: stream() emits text or binary chunks under any content type, app.stream() consumes them via fetch dispatching on the response type

The channel beside the command stream, for what is not JSON: an image, a PDF, a zip, a file to download. Give it a content type, and a name when it should arrive as a download rather than be shown. On the page app.stream() picks its handling from the type that came back, so a caller does not have to know in advance what it is getting.

streambinaryrawdatadownload
	$res = %res
	$cli = %req->cli
	!$res->streaming && $res->done && error('Output already started, invalid stream()')
	if (!$res->streaming){
		$res->streaming = true
		$res->type = $type
		$res->header('Cache-Control', 'no-store')
		$res->header('X-Content-Type-Options', 'nosniff')
		$res->header('X-Accel-Buffering', 'no')
		$name === null || $res->header('Content-Disposition', 'attachment; filename="'.str_replace('"', '', $name).'"')
		$res->render()
	}
	if ($data === null) return
	foreach (is_iterable($data) ? $data : [$data] as $part){
		print((string)$part)
		$cli || [@ob_flush(), flush()]
	}
function

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

/phlo/resources/tag.phlo line 10

Generate HTML tag string with attributes

One function for every element: attributes are named arguments, an underscore becomes a dash, so data_id turns into data-id, and true gives a bare attribute like required. Values are escaped, and an attribute with null is left out, which is what lets an optional attribute be written without a condition around it. Pass no inner content and you get a single tag without a closing one.

taghtmlrenderview
Generates an HTML tag with the specified name, optional inner content, and additional attributes.
"<$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 14

DOM form tags for button, input, select and textarea

button(), input(), select() and textarea() are tag() with the name filled in, so they take the same named arguments and the same escaping. They exist because these four appear so often that the name is noise.

formtagshtmlview
Creates a 'textarea' HTML element with the specified arguments.
tag('textarea', ...$args)
function

time_human(?int $time = null):string

/phlo/resources/time.human.phlo line 9

Convert timestamp age to human label

Rounds a timestamp to one unit that reads well, so a moment ago and last year both come out short. Define tsLabels to say it in another language; the frontend has the same trick with app.tsLabels, so both sides can speak the visitor's language.

timehumanageformat
Converts a given timestamp into a human-readable time difference format, such as '2 days' or '3 hours'. If no timestamp is provided, it uses the current time.
	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 = daemon, $wsExcept = void, ...$data)

/phlo/resources/wsCast.phlo line 10

Broadcast a message to WebSocket clients via the daemon's cast bridge

Sends a message to connected clients from anywhere in the backend, without a socket of your own: it goes over the daemon's bridge. wsTarget picks who, and wsExcept leaves one connection out, which is how you avoid echoing an action back to the person who caused it. It needs a running daemon, so it does nothing on a plain web process.

websocketcastrealtimehttpdaemon
HTTP (
	'http://127.0.0.1:'.$wsPort.'/message',
	JSON: true,
	POST: arr (
		host: $wsHost,
		target: $wsTarget,
		except: $wsExcept,
		data: $data,
	),
)

Last updated on 23 August 2026

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