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
该控制器检索当前的cookie状态并将其分配给objData属性。
this->objData = $_COOKIE
prop

%cookies -> lifetimeDays:int

line 12
设置 cookies 的生命周期(以天为单位)。
180
method

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

line 14
使用指定的键和值设置一个cookie,并可以选择性地指定过期时间、路径、安全性和SameSite属性。
$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
通过从本地对象数据和全局 $_COOKIE 数组中取消设置来删除 cookie,并将其到期日期设置为过去。
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
使用指定的参数格式将给定文本翻译成荷兰语。
%lang->translation('nl', $text, ...$args)
function

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

line 12
此函数检索指定文本的英文翻译,并可以选择性地使用附加参数进行格式化。
%lang->translation('en', $text, ...$args)
static

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

line 14
异步执行批量翻译,解码 JSON 输入,并在成功时保存翻译。
%app->lang = $to
$texts = json_decode($json, true)
$translations = $this->translateBatch($from, $to, $texts)
if ($translations) $this->save($to, $translations)
view

%lang -> view

line 21
此函数检索应用程序的当前语言设置,从而允许视图的本地化。
%app->lang
prop

%lang -> model:string

line 23
此函数检索与指定语言标识符相关联的模型。
'gpt-4o-mini'
prop

%lang -> instructions

line 24
定义了 Phlo 编程语言的一组指令。
void
static

lang :: fileCache:array

line 25
lang::$fileCache 是一个静态属性,用于存储缓存的语言文件,以便在运行时高效检索。
[]
method

%lang -> file ($lang):string

line 27
获取指定语言的配置文件,格式为 'langs.$lang.ini'。
langs.$lang.'.ini'
method

%lang -> escape ($value):string

line 29
对字符串中的特殊字符进行转义,以便安全地输出到HTML中,将反斜杠、双引号和换行符替换为各自的转义序列。
strtr((string)$value, [bs => bs.bs, dq => bs.dq, lf => '\n'])
method

%lang -> unescape ($value):string

line 30
此函数通过用相应的字符替换转义序列来解码给定字符串。
strtr(strtr($value, [bs.bs => "\x01", bs.dq => dq, '\n' => lf]), ["\x01" => bs])
method

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

line 32
从给定字符串中提取和处理行值,去除周围的引号(如果存在)并对任何特殊字符进行反转义。
$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
从指定文件中读取所有键值对,并将其作为关联数组返回。如果文件不存在或不是有效文件,则返回空数组。
$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
根据提供的哈希值在语言文件缓存中查找值,如果文件已更改,则更新缓存。
$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
如果可用,则从应用程序作者那里检索有关目的和领域的上下文;否则返回 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
从 'Accept-Language' HTTP 标头中提取首选语言,返回应用程序支持的语言中第一个匹配的语言代码。
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
从 cookies 中获取语言偏好,并检查它是否是应用程序可用语言中的有效选项,如果有效则返回该语言,否则返回 null。
($lang = %cookies->lang) && %app->langs[$lang] ? $lang : null
method

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

line 133
检测给定文本的语言并返回ISO 639-1代码。如果检测失败,则返回指定的后备语言代码,默认为'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
根据提供的文本和指定语言的前缀生成哈希值。
$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
将给定文本从指定语言翻译为应用程序当前语言,并异步处理缺失的翻译。
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
使用AI将给定文本从一种ISO 639-1语言翻译成另一种,同时保留markdown格式和大写字母。
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
使用AI将一批文本从一种语言翻译成另一种语言,以编号格式返回翻译结果。
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
该路由在通过GET请求访问时生成用于SEO的站点地图。
output($this)
route

route GET robots.txt

line 13
定义一个路由,响应GET请求,通过以UTF-8字符集的纯文本格式输出robots文件的内容。
output($this->robots(), type: 'text/plain; charset=utf-8')
method

%seo -> robots:string

line 15
根据应用程序的设置生成robots.txt文件内容,指定哪些用户代理被允许或禁止访问某些路径,并包含指向网站地图的链接。
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
从应用程序的 slugs 中检索 URI 的国际化版本,如果未找到翻译,则返回原始 URI。
(%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
此函数检索与应用程序语言设置对应的区域代码,如果未设置,则默认为'en'。
$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
这使用 title() 函数检索用于 SEO 目的的 Open Graph 标题。
title()
prop

%seo -> ogDescr

line 56
获取应用程序的描述,如果未设置则返回 void。
%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
这从请求对象中检索用于SEO目的的规范URL。
%req->url
prop

%seo -> ogType:string

line 60
为SEO目的设置Open Graph类型,具体为'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
这个表达式将应用的标题或id的值赋给变量$siteName,以用于SEO目的。
%app->title ?? id
prop

%seo -> twitterCard:bool

line 64
seo->$twitterCard 属性用于配置视图的 Twitter Card 元数据,从而更好地与 Twitter 分享功能集成。
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
seo->$noIndex 表达式在 %app->noLink 和 %app->noIndex 都未设置时评估为 true,否则返回 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
生成一个用于SEO目的的替代链接元素,指定当前视图的语言和URL。
<xhtml:link rel=alternate hreflang="$lang" href="%req->base$uri"{{ slash }}>
view
line 109
生成一个用于SEO目的的替代链接标签,指定资源的语言和URL。
<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
将指定键的会话变量设置为给定值。
$_SESSION[$key] = $this->objData[$key] = $value
method

%session -> __unset ($key)

line 16
从会话数据和内部对象数据中删除指定的键。
unset($this->objData[$key], $_SESSION[$key])
method

%session -> __isset ($key):bool

line 17
检查由给定键标识的会话变量是否已设置且不为 null。
isset($this->objData[$key])
method

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

line 19
为当前会话重新生成会话ID,基于$deleteOld参数可选择性地删除旧的会话数据。
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
访问任务的目录路径,特别指向'tasks/'。
data.'tasks/'
static

tasks :: run:void

line 12
通过检查任务的到期状态并锁定它们以防止并发执行来执行计划任务。它保存运行细节,并在执行后将任务标记为完成。
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
将运行数据保存到指定目录中的JSON文件,使用提供的名称、do、schedule和return值。
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
执行由 Closure、'Class::method' 字符串或资源名称字符串定义的任务,并返回执行结果。
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
从文件中检索任务的最后运行时间戳,如果文件不存在则返回0。
$file = static::dir().$name.'.last'
return is_file($file) ? (int)file_get_contents($file) : 0
static

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

line 66
将任务最后运行的时间戳写入指定目录中以任务命名的文件,使用独占锁定以防止并发写入。
file_put_contents(static::dir().$name.'.last', (string)$ts, LOCK_EX)
static

tasks :: lock ($name):bool

line 68
如果任务的锁文件不存在或超过一个小时,则创建一个锁文件。
$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
删除与任务相关的锁定文件,从而允许其再次执行。
@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
该表达式从请求对象中获取用户代理字符串,如果未设置则返回null。
%req->userAgent ?: null
prop

%useragent -> os:string

line 12
通过将用户代理字符串与预定义模式进行匹配来确定操作系统。
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
从用户代理字符串中提取操作系统版本(如果可用),并以清洁格式返回。
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
返回完整的操作系统名称及其版本(如果可用);否则返回'未知'或仅操作系统名称。
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
根据提供的用户代理字符串确定网页浏览器的名称。它检查各种模式以识别流行的浏览器,如Chrome、Firefox和Safari,如果没有找到匹配项,则返回'未知'。
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
从用户代理字符串中提取版本号,如果匹配特定浏览器模式,则返回清理后的版本号或在未找到匹配时返回void。
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
返回完整的用户代理字符串,包括用户代理的名称和版本,格式化以排除不必要的部分。
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
根据存储在 source 属性中的用户代理字符串确定设备类型(平板电脑、手机或桌面)。
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
'visitors::$table' 指的是与 Phlo 中的 'visitors' 资源相关联的数据库表。
'visitors'
static

visitors :: columns

line 13
定义了访客资源的列,指定了在数据操作中使用的属性。
'id,token,host,page,lang,IP,browser,os,device,active_seconds,state,width,height,referrer,created,changed'
static

visitors :: history:array

line 15
检索访客数量的历史记录,按日期分组并计算独特的令牌和总访问次数。
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
这获取在过去9秒内发生变化的独特在线访客的数量。
static::item(columns: 'COUNT(DISTINCT token)', where: 'changed >= (UNIX_TIMESTAMP() - 9)')
static

visitors :: lastHour:int

line 17
检索在过去一小时内发生变化的独特访客(令牌)的数量。
static::item(columns: 'COUNT(DISTINCT token)', where: 'changed >= (UNIX_TIMESTAMP() - 3600)')
static

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

line 19
通过将用户代理字符串与预定义的正则表达式模式进行匹配,来判断访问者是否为机器人。
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
解析引用URL以识别使用的搜索引擎,返回一个格式化字符串,指示搜索引擎或在未找到匹配项时返回主机。
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
处理更新访客心跳信息的PUT请求,包括同意、会话持续时间和页面交互,同时将数据存储在数据库中。
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
通过测量活跃时间并将心跳数据发送到服务器来跟踪页面上的用户活动,包括用户状态和可见性状态。
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
使用指定的主机、令牌和套接字参数建立WebSocket连接。
!function_exists('wsConnect') || wsConnect($wsHost, $wsToken, $wsSocket)
static

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

line 11
检查 'wsAuth' 函数是否存在,如果不存在,则使用提供的参数调用它进行 WebSocket 身份验证。
!function_exists('wsAuth') || wsAuth($wsHost, $wsToken, $wsSocket)
static

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

line 12
使用指定的主机、令牌和套接字从WebSocket连接接收数据,并将传入数据处理为JSON。
function_exists('wsReceive') && wsReceive($wsHost, $wsToken, $wsSocket, ...json_decode($data, true))
static

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

line 13
使用指定的主机、令牌和套接字关闭WebSocket连接。
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
使用指定的URL和密钥初始化WhatsApp实例,确保URL以斜杠结尾。
$this->url = rtrim($url, slash).slash
static

WhatsApp :: channel ($channel):static

line 13
使用提供的URL和密钥创建WhatsApp频道的新实例,如果未指定,则默认为'http://localhost:8081'和'void'。
new static($channel->configData->url ?? 'http://localhost:8081', $channel->secretData->secret ?? void)
method

%WhatsApp -> number ($contact):string

line 15
从WhatsApp联系人字符串中提取电话号码,如果格式无效则返回错误。
($pos = strpos($contact, '@')) ? substr($contact, 0, $pos) : error('Invalid contact: '.esc($contact))
method

%WhatsApp -> isGroup ($contact):bool

line 16
通过验证联系人是否包含 '@g' 后缀来检查指定的联系人是否为 WhatsApp 群组。
last($this->number($contact), (bool)strpos($contact, '@g'))
method

%WhatsApp -> status:obj

line 18
使用GET请求从WhatsApp获取当前状态。
$this->request('status', GET: true)
method

%WhatsApp -> health:obj

line 19
通过发送 GET 请求检查 WhatsApp 服务的健康状态。
$this->request('health', GET: true)
method

%WhatsApp -> qr:obj

line 20
发送请求以检索WhatsApp身份验证的二维码。
$this->request('qr', GET: true)
method

%WhatsApp -> disconnect:obj

line 21
通过发送断开请求来断开当前的WhatsApp会话。
$this->request('disconnect')
method

%WhatsApp -> read ($chat):obj

line 23
发送请求以读取指定WhatsApp聊天中的消息。
$this->request('read', chat: $chat)
method

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

line 24
向WhatsApp中的指定消息发送反应表情符号。
$this->request('reaction', msg: $msg, emoji: $emoji)
method

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

line 26
通过WhatsApp向指定的收件人发送文本消息。
$this->request('text', to: $to, text: $text)
method

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

line 27
通过WhatsApp向指定的收件人发送图像消息,选项包括附加文本消息。
$this->request('image', to: $to, filename: $file->name, image: $file->src, text: $text)
method

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

line 28
通过WhatsApp向指定的收件人发送带有纬度、经度和可选文本的位置消息。
$this->request('location', to: $to, lat: $lat, lon: $lon, text: $text)
method

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

line 29
通过WhatsApp将文档发送给指定的收件人,包括可选的文本消息。
$this->request('document', to: $to, filename: $file->name, document: $file->src, text: $text)
method

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

line 31
使用提供的音频文件向指定的接收者发送音频消息。
$this->request('audio', to: $to, audio: $file->src)
method

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

line 32
使用提供的音频文件向指定的收件人发送语音消息。
$this->request('voice', to: $to, audio: $file->src)
method

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

line 34
向指定的WhatsApp接收者发送带有给定选项的投票消息,如果指定,则允许多选。
$this->request('poll', to: $to, name: $name, options: $options, multi: $multi)
method

%WhatsApp -> startTyping ($to):obj

line 36
为WhatsApp中的指定收件人启动输入指示器。
$this->request('typing/start', to: $to)
method

%WhatsApp -> stopTyping ($to):obj

line 37
停止WhatsApp中特定收件人的输入指示器。
$this->request('typing/stop', to: $to)
method

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

line 39
向WhatsApp API发送指定操作和数据的请求,返回一个指示成功或失败的响应对象。
$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
为HTML元素生成类属性字符串,如果条件为真,则将'active'添加到指定的类列表中。
$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
通过从当前时间中减去给定的时间来计算年龄。
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
根据给定的年龄(以秒为单位)计算可读的时间持续时间。
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
等待多个作业完成,处理它们的输出和错误,并返回结果。它管理子进程,并确保它们不会无限期阻塞。
	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
创建一个按钮元素,使用作为 props 传递的指定参数。
tag('button', ...$args)
function

camel(string $text):string

/phlo/resources/camel.phlo line 9

Convert text to camelCase

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

camelcasestringformat
通过将每个单词的首字母大写并去除空格,将给定字符串转换为驼峰式。
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
通过使用可迭代对象中的值作为键,并使用可选的值回调来确定相应的值,创建一个关联数组。
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
此函数检索指定文本的英文翻译,并可以选择性地使用附加参数进行格式化。
%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
在视图中创建一个具有指定参数的输入元素。
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
向 n8n webhook 发送一个可选数据和测试标志的 HTTP POST 请求。
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
使用指定的参数格式将给定文本翻译成荷兰语。
%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.

通过HTTP将指定标题、正文、类型、级别和可选用户的通知发送到配置的URL。
	$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
根据提供的名称和参数创建或检索Phlo对象的实例,管理一个静态对象列表以供重用。
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
通过设置环境、加载必要的类和函数,并根据提供的参数配置各种运行时参数来初始化Phlo应用程序。
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
异步执行回调函数,传递任何附加参数,如果成功则返回进程ID。
	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
根据提供的目标字符串,将方法调用分发到对象或类的静态方法,可选择性地传递参数。
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
通过将Throwable对象传递给phlo_error_handle函数来处理异常。
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
检查指定的 PHP 文件是否存在于给定的对象路径中。
is_file(php.strtr($obj, [us => dot]).'.php')
function

phlo_load(bool $http):void

/phlo/phlo.php line 154
加载应用程序所需的运行时文件,确保应用程序仅加载一次,并为HTTP响应设置正确的内容类型。
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
从回调函数流式输出,允许异步处理数据。它可以在守护进程模式下运行或在CLI上下文中执行命令。
	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
以同步方式执行回调函数,并将任何附加参数传递给它,返回结果作为 JSON 对象或在执行失败时的错误消息。
	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
创建一个 'select' HTML 元素,使用提供的参数作为属性和选项。
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
将给定字符串转换为URL友好的slug,通过删除非字母数字字符、转换为小写并用短横线替换空格。
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
生成一个具有指定名称、可选内部内容和附加属性的HTML标签。
"<$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
创建一个带有指定参数的 'textarea' HTML 元素。
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
将给定的时间戳转换为人类可读的时间差格式,例如 '2天' 或 '3小时'。如果未提供时间戳,则使用当前时间。
	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,
	),
)

最近更新于 2026年8月23日

我们使用必要的cookie来使该网站正常工作。在您的许可下,我们还使用分析工具来改善网站。