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
Deze controller haalt de huidige staat van cookies op en wijst deze toe aan de objData-eigenschap.
this->objData = $_COOKIE
prop

%cookies -> lifetimeDays:int

line 12
Stelt de levensduur van cookies in dagen in.
180
method

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

line 14
Stelt een cookie in met de opgegeven sleutel en waarde, samen met optionele parameters voor vervaldatum, pad, beveiliging en SameSite-attributen.
$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
Verwijdert een cookie door deze uit de lokale objectgegevens en de globale $_COOKIE-array te verwijderen, en stelt de vervaldatum in op het verleden.
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
Vertaal de gegeven tekst naar het Nederlands met de opgegeven argumenten voor opmaak.
%lang->translation('nl', $text, ...$args)
function

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

line 12
Deze functie haalt een vertaling op voor de opgegeven tekst in het Engels, en kan optioneel worden opgemaakt met extra argumenten.
%lang->translation('en', $text, ...$args)
static

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

line 14
Voert een batchvertaling asynchroon uit, decodeert JSON-invoer en slaat de vertalingen op als deze succesvol zijn.
%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
Deze functie haalt de huidige taalinstelling van de applicatie op, waardoor lokalisatie van views mogelijk is.
%app->lang
prop

%lang -> model:string

line 23
Deze functie haalt het model op dat is gekoppeld aan de opgegeven taalidentificator.
'gpt-4o-mini'
prop

%lang -> instructions

line 24
Definieert een set instructies voor de Phlo programmeertaal.
void
static

lang :: fileCache:array

line 25
lang::$fileCache is een statische eigenschap die gecachete taaldocumenten opslaat voor efficiënte toegang tijdens runtime.
[]
method

%lang -> file ($lang):string

line 27
Haal het configuratiebestand op voor de opgegeven taal, met het formaat 'langs.$lang.ini'.
langs.$lang.'.ini'
method

%lang -> escape ($value):string

line 29
Escaped speciale tekens in een string voor veilige uitvoer in HTML, vervangend backslashes, dubbele aanhalingstekens en regeleinden met hun respectieve escape-sequenties.
strtr((string)$value, [bs => bs.bs, dq => bs.dq, lf => '\n'])
method

%lang -> unescape ($value):string

line 30
Deze functie ontsnapt een gegeven string door escape-sequenties te vervangen door hun overeenkomstige karakters.
strtr(strtr($value, [bs.bs => "\x01", bs.dq => dq, '\n' => lf]), ["\x01" => bs])
method

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

line 32
Extraheert en verwerkt een lijnwaarde uit een gegeven string, verwijdert omringende aanhalingstekens indien aanwezig en ontsnapt speciale tekens.
$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
Leest alle sleutel-waarde paren uit een opgegeven bestand en retourneert deze als een associatieve array. Als het bestand niet bestaat of geen geldig bestand is, retourneert het een lege 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
Zoekt een waarde in de taalbestanden cache op basis van de opgegeven hash en werkt de cache bij als het bestand is gewijzigd.
$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
Haal de context op van de app-auteur met betrekking tot het doel en het domein, indien beschikbaar; anders retourneert het 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
Haal de voorkeurstaal uit de 'Accept-Language' HTTP-header, en retourneer de eerste overeenkomende taalcodes uit de ondersteunde talen van de applicatie.
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
Haal de taalvoorkeur uit cookies en controleer of het een geldige optie is in de beschikbare talen van de applicatie, waarbij de taal wordt geretourneerd als deze geldig is of null anders.
($lang = %cookies->lang) && %app->langs[$lang] ? $lang : null
method

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

line 133
Detecteert de taal van de gegeven tekst en retourneert de ISO 639-1 code. Als de detectie mislukt, retourneert het een opgegeven fallback-taalcode, standaard '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
Genereert een hash op basis van de opgegeven tekst en een voorvoegsel van de opgegeven taal.
$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
Vertaling van de gegeven tekst van een opgegeven taal naar de huidige taal van de applicatie, waarbij ontbrekende vertalingen asynchroon worden afgehandeld.
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
Vertaal een gegeven tekst van de ene ISO 639-1 taal naar de andere met behulp van AI, terwijl markdown-opmaak en hoofdletters behouden blijven.
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
Vertaal een batch tekst van de ene naar de andere taal met behulp van AI, en geef de vertalingen in genummerd formaat terug.
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
Deze route genereert een sitemap voor SEO-doeleinden wanneer deze via een GET-verzoek wordt benaderd.
output($this)
route

route GET robots.txt

line 13
Definieert een route die reageert op GET-verzoeken door de inhoud van het robots-bestand in platte tekstformaat met UTF-8-charset uit te geven.
output($this->robots(), type: 'text/plain; charset=utf-8')
method

%seo -> robots:string

line 15
Genereert de inhoud van een robots.txt-bestand op basis van de instellingen van de applicatie, waarbij wordt gespecificeerd welke gebruikersagenten zijn toegestaan of geweerd bij het toegang krijgen tot bepaalde paden, en voegt een link naar de sitemap toe.
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
Haal de geïndividualiseerde versie van een URI op uit de slugs van de applicatie, en retourneer de originele URI als er geen vertaling wordt gevonden.
(%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
Deze functie haalt de locale-code op die overeenkomt met de taalinstelling van de applicatie, met 'en' als standaard als deze niet is ingesteld.
$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
Dit haalt de Open Graph-titel op voor SEO-doeleinden met behulp van de title() functie.
title()
prop

%seo -> ogDescr

line 56
Haal de beschrijving van de applicatie op, retourneert void als deze niet is ingesteld.
%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
Dit haalt de canonical URL op voor SEO-doeleinden uit het aanvraagobject.
%req->url
prop

%seo -> ogType:string

line 60
Stelt het Open Graph-type in voor SEO-doeleinden, specifiek op '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
Deze expressie wijst de waarde van de titel van de app of de id toe aan de variabele $siteName voor SEO-doeleinden.
%app->title ?? id
prop

%seo -> twitterCard:bool

line 64
De seo->$twitterCard eigenschap wordt gebruikt om de Twitter Card metadata voor een view te configureren, waardoor een betere integratie met Twitter deelfunctionaliteiten mogelijk is.
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
De seo->$noIndex expressie evalueert naar true als zowel %app->noLink als %app->noIndex niet zijn ingesteld, anders retourneert het 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
Genereert een alternatieve linkelement voor SEO-doeleinden, waarbij de taal en URL voor de huidige weergave worden gespecificeerd.
<xhtml:link rel=alternate hreflang="$lang" href="%req->base$uri"{{ slash }}>
view
line 109
Genereert een alternatieve link-tag voor SEO-doeleinden, die de taal en de URL van de bron specificeert.
<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
Stelt een sessievariabele in met de opgegeven sleutel op de gegeven waarde.
$_SESSION[$key] = $this->objData[$key] = $value
method

%session -> __unset ($key)

line 16
Verwijdert de opgegeven sleutel uit de sessiegegevens en de interne objectgegevens.
unset($this->objData[$key], $_SESSION[$key])
method

%session -> __isset ($key):bool

line 17
Controleert of een sessievariabele die door de gegeven sleutel is geïdentificeerd, is ingesteld en niet null is.
isset($this->objData[$key])
method

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

line 19
Regenerates de sessie-ID voor de huidige sessie, waarbij optioneel de oude sessiegegevens worden verwijderd op basis van de $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
Toegang tot het pad van de directory voor taken, specifiek verwijzend naar 'tasks/'.
data.'tasks/'
static

tasks :: run:void

line 12
Voert geplande taken uit door hun vervaldatum te controleren en ze te vergrendelen om gelijktijdige uitvoering te voorkomen. Het slaat de uitvoeringsdetails op en markeert de taak als voltooid na uitvoering.
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
Slaat de uitvoergegevens op in een JSON-bestand in de opgegeven map, met de opgegeven naam, do, schema en return-waarden.
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
Voert een taak uit die is gedefinieerd door een Closure, een 'Class::method' string of een resource-naam string, en retourneert het resultaat van de uitvoering.
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
Haal de laatste uitvoeringstijdstempel van een taak op uit een bestand, en retourneer 0 als het bestand niet bestaat.
$file = static::dir().$name.'.last'
return is_file($file) ? (int)file_get_contents($file) : 0
static

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

line 66
Schrijft de tijdstempel van de laatste uitvoering van een taak naar een bestand met de naam van de taak in de opgegeven directory, met gebruik van exclusieve vergrendeling om gelijktijdige schrijfbewerkingen te voorkomen.
file_put_contents(static::dir().$name.'.last', (string)$ts, LOCK_EX)
static

tasks :: lock ($name):bool

line 68
Maakt een lockbestand voor een taak aan als het nog niet bestaat of ouder is dan een uur.
$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
Verwijdert het vergrendelingsbestand dat aan een taak is gekoppeld, zodat deze opnieuw kan worden uitgevoerd.
@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
Deze expressie haalt de user agent-string op uit het aanvraagobject en retourneert null als deze niet is ingesteld.
%req->userAgent ?: null
prop

%useragent -> os:string

line 12
Bepaalt het besturingssysteem op basis van de user agent-string door deze te vergelijken met vooraf gedefinieerde patronen.
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
Haalt de versie van het besturingssysteem uit de user agent-string als deze beschikbaar is, en retourneert deze in een schone indeling.
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
Geeft de volledige naam van het besturingssysteem terug, samen met de versie als deze beschikbaar is; anders retourneert het 'Onbekend' of alleen de naam van het besturingssysteem.
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
Bepaalt de naam van de webbrowser op basis van de gebruikersagentstring die in de source is opgegeven. Het controleert verschillende patronen om populaire browsers zoals Chrome, Firefox en Safari te identificeren en retourneert 'Onbekend' als er geen overeenkomst wordt gevonden.
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
Haalt het versienummer uit de user agent-string als deze overeenkomt met specifieke browserpatronen, en retourneert het schoongemaakte versienummer of void als er geen overeenkomst is.
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
Geeft de volledige user agent-string terug, inclusief de naam en versie van de user agent, geformatteerd om onnodige delen uit te sluiten.
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
Bepaalt het type apparaat (Tablet, Telefoon of Desktop) op basis van de user agent-string die is opgeslagen in de source-eigenschap.
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
De 'visitors::$table' verwijst naar de database tabel die is gekoppeld aan de 'visitors' resource in Phlo.
'visitors'
static

visitors :: columns

line 13
Definieert de kolommen voor de visitors resource, waarbij de te gebruiken attributen voor gegevensbewerkingen worden gespecificeerd.
'id,token,host,page,lang,IP,browser,os,device,active_seconds,state,width,height,referrer,created,changed'
static

visitors :: history:array

line 15
Haal een geschiedenis op van bezoekersaantallen, gegroepeerd op datum en tel unieke tokens en totale bezoeken.
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
Dit haalt het aantal unieke online bezoekers op die in de afgelopen 9 seconden zijn veranderd.
static::item(columns: 'COUNT(DISTINCT token)', where: 'changed >= (UNIX_TIMESTAMP() - 9)')
static

visitors :: lastHour:int

line 17
Haal het aantal unieke bezoekers (tokens) op die in het afgelopen uur zijn veranderd.
static::item(columns: 'COUNT(DISTINCT token)', where: 'changed >= (UNIX_TIMESTAMP() - 3600)')
static

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

line 19
Bepaalt of de user agent-string aangeeft dat de bezoeker een bot is door deze te vergelijken met een vooraf gedefinieerd regex-patroon.
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
Analyseert de referrer-URL om de gebruikte zoekmachine te identificeren en retourneert een geformatteerde string die de zoekmachine aangeeft of de host als er geen overeenkomst wordt gevonden.
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
Behandelt het PUT-verzoek voor het bijwerken van hartslaginformatie van bezoekers, inclusief toestemming, sessieduur en pagina-interacties, terwijl de gegevens in de database worden opgeslagen.
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
Volgt gebruikersactiviteit op de pagina door actieve tijd te meten en heartbeat-gegevens naar de server te verzenden, inclusief gebruikersstatus en zichtbaarheidstoestand.
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
Stelt een WebSocket-verbinding tot stand met behulp van de opgegeven host, token en socketparameters.
!function_exists('wsConnect') || wsConnect($wsHost, $wsToken, $wsSocket)
static

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

line 11
Controleert of de 'wsAuth'-functie bestaat en roept deze, indien niet, aan met de opgegeven parameters voor WebSocket-authenticatie.
!function_exists('wsAuth') || wsAuth($wsHost, $wsToken, $wsSocket)
static

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

line 12
Ontvangt gegevens van een WebSocket-verbinding met behulp van de opgegeven host, token en socket, en verwerkt de binnenkomende gegevens als JSON.
function_exists('wsReceive') && wsReceive($wsHost, $wsToken, $wsSocket, ...json_decode($data, true))
static

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

line 13
Sluit een WebSocket-verbinding met behulp van de opgegeven host, token en 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
Initialiseert een WhatsApp-instantie met een opgegeven URL en geheim, waarbij wordt gegarandeerd dat de URL eindigt met een schuine streep.
$this->url = rtrim($url, slash).slash
static

WhatsApp :: channel ($channel):static

line 13
Maakt een nieuwe instantie van het WhatsApp-kanaal met de opgegeven URL en geheim, standaard 'http://localhost:8081' en 'void' als niet gespecificeerd.
new static($channel->configData->url ?? 'http://localhost:8081', $channel->secretData->secret ?? void)
method

%WhatsApp -> number ($contact):string

line 15
Extraheert het telefoonnummer uit een WhatsApp-contactstring en retourneert een fout als het formaat ongeldig is.
($pos = strpos($contact, '@')) ? substr($contact, 0, $pos) : error('Invalid contact: '.esc($contact))
method

%WhatsApp -> isGroup ($contact):bool

line 16
Controleert of de opgegeven contactpersoon een WhatsApp-groep is door te verifiëren of de contactpersoon een '@g' achtervoegsel bevat.
last($this->number($contact), (bool)strpos($contact, '@g'))
method

%WhatsApp -> status:obj

line 18
Haal de huidige status van WhatsApp op met een GET-verzoek.
$this->request('status', GET: true)
method

%WhatsApp -> health:obj

line 19
Controleert de gezondheidsstatus van de WhatsApp-service door een GET-verzoek te verzenden.
$this->request('health', GET: true)
method

%WhatsApp -> qr:obj

line 20
Stuurt een verzoek om de QR-code voor WhatsApp-authenticatie op te halen.
$this->request('qr', GET: true)
method

%WhatsApp -> disconnect:obj

line 21
Verbreekt de huidige WhatsApp-sessie door een verbreekverzoek te sturen.
$this->request('disconnect')
method

%WhatsApp -> read ($chat):obj

line 23
Stuurt een verzoek om berichten te lezen uit een opgegeven WhatsApp-chat.
$this->request('read', chat: $chat)
method

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

line 24
Stuurt een reactie-emoticon naar een specifiek bericht in WhatsApp.
$this->request('reaction', msg: $msg, emoji: $emoji)
method

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

line 26
Verzendt een tekstbericht naar de opgegeven ontvanger via WhatsApp.
$this->request('text', to: $to, text: $text)
method

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

line 27
Verzendt een afbeeldingsbericht via WhatsApp naar de opgegeven ontvanger, met de optie om een tekstbericht toe te voegen.
$this->request('image', to: $to, filename: $file->name, image: $file->src, text: $text)
method

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

line 28
Verzendt een locatiebericht via WhatsApp naar de opgegeven ontvanger met breedtegraad, lengtegraad en optionele tekst.
$this->request('location', to: $to, lat: $lat, lon: $lon, text: $text)
method

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

line 29
Verzendt een document via WhatsApp naar de opgegeven ontvanger, inclusief een optioneel tekstbericht.
$this->request('document', to: $to, filename: $file->name, document: $file->src, text: $text)
method

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

line 31
Verzendt een audiobericht naar een opgegeven ontvanger met het opgegeven audiobestand.
$this->request('audio', to: $to, audio: $file->src)
method

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

line 32
Verzendt een spraakbericht naar een opgegeven ontvanger met behulp van het opgegeven audiobestand.
$this->request('voice', to: $to, audio: $file->src)
method

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

line 34
Verzendt een peilingbericht naar een opgegeven WhatsApp-ontvanger met de gegeven opties, met de mogelijkheid voor meerdere selecties indien opgegeven.
$this->request('poll', to: $to, name: $name, options: $options, multi: $multi)
method

%WhatsApp -> startTyping ($to):obj

line 36
Start de typindicator voor een opgegeven ontvanger in WhatsApp.
$this->request('typing/start', to: $to)
method

%WhatsApp -> stopTyping ($to):obj

line 37
Stop de typindicator voor een specifieke ontvanger in WhatsApp.
$this->request('typing/stop', to: $to)
method

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

line 39
Verzendt een verzoek naar de WhatsApp API met de opgegeven actie en gegevens, en retourneert een responsobject dat succes of falen aangeeft.
$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
Genereert een class-attribuutstring voor HTML-elementen, waarbij 'active' aan de opgegeven classlijst wordt toegevoegd als de voorwaarde waar is.
$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
Berechnet de leeftijd door de gegeven tijd van de huidige tijd af te trekken.
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
Berechnet een leesbare tijdsduur op basis van de gegeven leeftijd in seconden.
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
Wacht op meerdere taken om te voltooien, beheert hun uitvoer en fouten, en retourneert de resultaten. Het beheert kindprocessen en zorgt ervoor dat ze niet eindeloos blokkeren.
	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
Maakt een knop element met de opgegeven argumenten die als props worden doorgegeven.
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
Converteert een gegeven string naar camel case door de eerste letter van elk woord te kapitaliseren en spaties te verwijderen.
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
Maakt een associatieve array door de waarden uit de iterable als sleutels te gebruiken en de optionele waarde callback te gebruiken om de bijbehorende waarden te bepalen.
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
Deze functie haalt een vertaling op voor de opgegeven tekst in het Engels, en kan optioneel worden opgemaakt met extra argumenten.
%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
Maakt een invoerelement in de view met de opgegeven argumenten.
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
Stuurt een HTTP POST-verzoek naar een n8n webhook met optionele gegevens en een testvlag.
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
Vertaal de gegeven tekst naar het Nederlands met de opgegeven argumenten voor opmaak.
%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.

Verzendt een melding met een opgegeven titel, inhoud, type, niveau en optionele gebruiker naar een geconfigureerde URL via 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
Maakt of haalt een instantie van een Phlo-object op op basis van de opgegeven naam en argumenten, en beheert een statische lijst van objecten voor hergebruik.
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
Initialiseert een Phlo-applicatie door de omgeving in te stellen, noodzakelijke klassen en functies te laden, en verschillende runtime-parameters te configureren op basis van de opgegeven argumenten.
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
Voert een callbackfunctie asynchroon uit, waarbij eventuele extra argumenten worden doorgegeven, en retourneert de proces-ID als het succesvol is.
	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
Verzendt een methode-aanroep naar een object of een statische methode van een klasse op basis van de opgegeven doelsstring, met optioneel doorgegeven argumenten.
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
Behandelt uitzonderingen door het Throwable-object door te geven aan de phlo_error_handle functie voor verwerking.
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
Controleert of een specifiek PHP-bestand bestaat op het opgegeven objectpad.
is_file(php.strtr($obj, [us => dot]).'.php')
function

phlo_load(bool $http):void

/phlo/phlo.php line 154
Laadt de noodzakelijke runtime-bestanden voor de applicatie, waarbij ervoor wordt gezorgd dat de applicatie slechts eenmaal wordt geladen en dat het juiste contenttype wordt ingesteld voor 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
Stroomt uitvoer van een callbackfunctie, waardoor asynchrone verwerking van gegevens mogelijk is. Het kan in een daemon-modus werken of een opdracht in de CLI-context uitvoeren.
	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
Voert een callbackfunctie synchronisch uit, waarbij eventuele extra argumenten worden doorgegeven, en retourneert het resultaat als een JSON-object of een foutmelding als de uitvoering mislukt.
	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
Maakt een 'select' HTML-element met de opgegeven argumenten als attributen en opties.
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
Converteert een gegeven string naar een URL-vriendelijke slug door niet-alfanumerieke tekens te verwijderen, naar kleine letters om te zetten en spaties door streepjes te vervangen.
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
Genereert een HTML-tag met de opgegeven naam, optionele inhoud en extra attributen.
"<$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
Maakt een 'textarea' HTML-element met de opgegeven argumenten.
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
Converteert een gegeven tijdstempel naar een leesbaar tijdsverschil formaat, zoals '2 dagen' of '3 uur'. Als er geen tijdstempel wordt opgegeven, wordt de huidige tijd gebruikt.
	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,
	),
)

Laatst bijgewerkt op 23-08-2026

We gebruiken essentiële cookies om deze site te laten werken. Met uw toestemming gebruiken we ook analytics om de site te verbeteren.