Core

object

%cookies

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

%cookies -> controller

line 9
该控制器检索当前的cookie状态并将其分配给objData属性。
this->objData = $_COOKIE
prop

%cookies -> lifetimeDays

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

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

line 13
使用指定的键和值设置一个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)

line 21
通过从本地对象数据和全局 $_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
version 1.0
creator q-ai.nl
summary Language and translation resource
package i18n
frontend false
backend true
requires @cookies @AI @INI phlo.async
advice Use %lang in views to show current app lang (for example in links)
tags lang translation i18n locale ai
function

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

line 11
使用指定的参数格式将给定文本翻译成荷兰语。
%lang->translation('nl', $text, ...$args)
function

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

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

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

line 14
异步执行批量翻译,解码 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

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

%lang -> instructions

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

lang :: fileCache

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

%lang -> file ($lang)

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

%lang -> escape ($value)

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

%lang -> unescape ($value)

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

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

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)

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)

line 49
在文件中搜索特定的哈希值,并在找到时返回其关联的值。
$size = (int)@filesize($file)
if (!$size) return null
$h = @fopen($file, 'rb')
if (!$h) return null
$lo = 0
$hi = $size
while ($hi - $lo > 4096){
	$mid = intdiv($lo + $hi, 2)
	fseek($h, $mid)
	fgets($h)
	$pos = ftell($h)
	if ($pos >= $hi){
		$hi = $mid
		continue
	}
	$line = (string)fgets($h)
	$eq = strpos($line, ' = ')
	if ($eq === false){
		$hi = $mid
		continue
	}
	$cmp = strcmp(substr($line, 0, $eq), $hash)
	if ($cmp < 0) $lo = ftell($h)
	elseif ($cmp > 0) $hi = $pos
	else {
		fclose($h)
		return $this->lineValue($line, $eq)
	}
}
fseek($h, $lo)
$value = null
while (ftell($h) < $hi && ($line = fgets($h)) !== false){
	$eq = strpos($line, ' = ')
	if ($eq === false) continue
	$cmp = strcmp(substr($line, 0, $eq), $hash)
	if ($cmp > 0) break
	if ($cmp === 0){
		$value = $this->lineValue($line, $eq)
		break
	}
}
fclose($h)
return $value
method

%lang -> lookup ($hash)

line 95
根据提供的哈希值在语言文件缓存中查找值,如果文件已更改,则更新缓存。
$file = $this->file(%app->lang)
$mtime = (int)@filemtime($file)
$cache =& static::$fileCache[$file]
if (!$cache || $cache['mtime'] !== $mtime) $cache = ['mtime' => $mtime, 'items' => []]
if (array_key_exists($hash, $cache['items'])) return $cache['items'][$hash]
$value = $this->search($file, $hash)
if ($value === null && $mtime) $value = $this->readAll($file)[$hash] ?? null
return $cache['items'][$hash] = $value
method

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

line 106
将一组键值对保存到语言文件中,确保键被排序并适当设置文件权限。
$file = $this->file($lang)
$items = $this->readAll($file)
foreach ($pairs AS $hash => $value) $items[$hash] = $value
ksort($items, SORT_STRING)
$out = void
foreach ($items AS $hash => $value) $out .= $hash.' = '.dq.$this->escape($value).dq.lf
$tmp = $file.'.'.getmypid().'.tmp'
file_put_contents($tmp, $out, LOCK_EX)
@chmod($tmp, 0664)
rename($tmp, $file)
unset(static::$fileCache[$file])
method

%lang -> transContext

line 120
如果可用,则从应用程序作者那里检索有关目的和领域的上下文;否则返回 void。
($instr = trim($this->instructions ?? void)) !== void ? lf.'Context from the app author about purpose and domain: '.$instr : void
prop

%lang -> browser

line 122
从 '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

line 123
从 cookies 中获取语言偏好,并检查它是否是应用程序可用语言中的有效选项,如果有效则返回该语言,否则返回 null。
($lang = %cookies->lang) && %app->langs[$lang] ? $lang : null
method

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

line 124
检测给定文本的语言并返回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)

line 133
根据提供的文本和指定语言的前缀生成哈希值。
$from.($short = substr(implode(regex_all('/[A-Za-z0-9]+/', ucwords($text))[0]), 0, 8)).substr(md5($text), 0, 10 - strlen($short))
method

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

line 134
将给定文本从指定语言翻译为应用程序当前语言,并异步处理缺失的翻译。
if ($from === %app->lang) $translation = strtr($text, ['\n' => lf])
else {
	$translation = []
	$missing = []
	foreach (explode(lf, $text) AS $line){
		if (trim($line)){
			$hash = $this->hash($from, $line)
			$item = $this->lookup($hash)
			if ($item === null) [$missing[$hash] = $item = $line, debug(%app->lang.': '.(strlen($line) > 20 ? substr($line, 0, 18).'...' : $line))]
		}
		else $item = void
		$translation[] = $item
	}
	if ($missing) phlo_async('lang::asyncBatch', $from, %app->lang, json_encode($missing))
	$translation = implode(lf, $translation)
}
return $args ? sprintf($translation, ...$args) : $translation
method

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

line 153
使用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)

line 162
使用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

%payload

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

%payload -> controller

line 10
contentType = %req->contentType
if (in_array(phlo('req')->method, ['POST', 'PUT', 'PATCH', 'QUERY']) && str_starts_with($contentType, 'application/json')){
$data = json_read('php://input')
return $this->objData = is_object($data) ? get_object_vars($data) : (is_array($data) ? $data : [])
}
if ($_POST) $this->objImport(...$_POST)
elseif (in_array(phlo('req')->method, ['PUT', 'PATCH', 'QUERY']) && str_starts_with($contentType, 'application/x-www-form-urlencoded')){
$body = file_get_contents('php://input')
$data = []
parse_str($body, $data)
if ($data) $this->objImport(...$data)
}
elseif (in_array(phlo('req')->method, ['PUT', 'PATCH', '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) $this->objImport(...loop($_FILES, fn($f) => is_array($f['name']) ? loop(array_keys($f['name']), fn($i) => $f['error'][$i] ? null : %file($f['tmp_name'][$i], $f['name'][$i], mime: $f['type'][$i], size: $f['size'][$i])) : ($f['error'] ? null : %file($f['tmp_name'], $f['name'], mime: $f['type'], size: $f['size']))))
object

%seo

/phlo/resources/seo.phlo
version 1.0
creator q-ai.nl
summary Multilingual SEO: sitemap.xml, robots.txt, hreflang + head meta (description/OG/Twitter/canonical)
package seo
frontend false
backend true
requires output
tags seo sitemap robots hreflang opengraph multilingual
route

route GET sitemap.xml

line 10
该路由在通过GET请求访问时生成用于SEO的站点地图。
output($this)
route

route GET robots.txt

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

%seo -> robots

line 14
根据应用程序的设置生成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)

line 22
从应用程序的 slugs 中检索 URI 的国际化版本,如果未找到翻译,则返回原始 URI。
(%app->slugs ?? [])[$uri] ?? $uri
method

%seo -> locale

line 24
此函数检索与应用程序语言设置对应的区域代码,如果未设置,则默认为'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

line 30
这使用 title() 函数检索用于 SEO 目的的 Open Graph 标题。
title()
prop

%seo -> ogDescr

line 31
获取应用程序的描述,如果未设置则返回 void。
%app->description ?? void
prop

%seo -> ogImage

line 32
此表达式用于检索Open Graph图像URL以用于SEO目的,如果未提供图像,则默认为'icon.webp'。
%req->base.slash.ltrim(%app->image ?? 'icon.webp', slash)
prop

%seo -> canonical

line 33
这从请求对象中检索用于SEO目的的规范URL。
%req->url
prop

%seo -> ogType

line 34
为SEO目的设置Open Graph类型,具体为'website'。
'website'
prop

%seo -> siteName

line 35
这个表达式将应用的标题或id的值赋给变量$siteName,以用于SEO目的。
%app->title ?? id
prop

%seo -> twitterCard

line 36
seo->$twitterCard 属性用于配置视图的 Twitter Card 元数据,从而更好地与 Twitter 分享功能集成。
false
prop

%seo -> noIndex

line 37
seo->$noIndex 表达式在 %app->noLink 和 %app->noIndex 都未设置时评估为 true,否则返回 false。
%app->noLink ?? %app->noIndex ?? false
view

%seo -> view

line 39
通过遍历定义的页面并包含其 URL,为应用程序生成 XML 网站地图。
<?xml version=1.0 encoding="UTF-8"?>
<urlset xmlns=http://www.sitemaps.org/schemas/sitemap/0.9 xmlns:xhtml=http://www.w3.org/1999/xhtml xmlns:xsi=http://www.w3.org/2001/XMLSchema-instance xsi:schemaLocation=http://www.sitemaps.org/schemas/sitemap/0.9+http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd>
	<foreach %app->pages AS $uri>
		{{ $this->page($uri) }}
	</foreach>
</urlset>
view

%seo -> page ($uri)

line 47
为视图生成一个SEO友好的XML网站地图条目,包括应用程序支持的每种语言的本地化URL。
<url>
	<loc>%req->base{( $uri ?: slash )}</loc>
	<foreach array_keys(%app->langs) AS $lang>
		<if $lang === %app->lang>
			{{ $this->xlink('x-default', $uri ?: slash) }}
		</if>
		{{ $this->xlink($lang, $lang === %app->lang ? ($uri ?: slash) : "/$lang".($this->intl($uri) ?: void)) }}
	</foreach>
</url>
view
line 58
生成一个用于SEO目的的替代链接元素,指定当前视图的语言和URL。
<xhtml:link rel=alternate hreflang="$lang" href="%req->base$uri"{{ slash }}>
view
line 59
生成一个用于SEO目的的替代链接标签,指定资源的语言和URL。
<link rel=alternate hreflang="$lang" href="%req->base$uri">
view

%seo -> head

line 61
为视图生成与SEO相关的元标签,包括基于提供的属性(如描述、标题和图像)的Open Graph和Twitter Card标签。
<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">
<meta property=og:image content="$this->ogImage">
<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">
	<meta name=twitter:image content="$this->ogImage">
</if>
<if !$this->noIndex>
	<link rel=canonical href="$this->canonical">
</if>
object

%session

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

%session -> controller

line 9
初始化会话并将会话数据分配给 objData 属性。
ession_start()
$this->objData = $_SESSION
method

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

line 12
将指定键的会话变量设置为给定值。
$_SESSION[$key] = $this->objData[$key] = $value
method

%session -> __unset ($key)

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

%session -> __isset ($key)

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

%session -> objRegenerateId ($deleteOld = true)

line 16
为当前会话重新生成会话ID,基于$deleteOld参数可选择性地删除旧的会话数据。
session_regenerate_id($deleteOld)
$this->objData = $_SESSION
object

%stream

/phlo/resources/stream.phlo
version 1.0
creator q-ai.nl
summary 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
package runtime
frontend true
backend true
provides app.stream
tags stream binary raw data download
function

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

line 10
$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 30
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 value = await res.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
version 1.0
creator q-ai.nl
summary Cron runner for %app->tasks. One cron entry per app triggers this every minute.
package scheduling
frontend false
backend true
tags cron schedule tasks scheduler
static

tasks :: dir

line 9
访问任务的目录路径,特别指向'tasks/'。
data.'tasks/'
static

tasks :: run

line 11
通过检查任务的到期状态并锁定它们以防止并发执行来执行计划任务。它保存运行细节,并在执行后将任务标记为完成。
is_dir(static::dir()) || mkdir(static::dir(), 0755, true)
$now = time()
foreach (%app->tasks ?? [] AS $name => $task){
	$task = (object)$task
	if (!static::due($name, $task, $now)) continue
	if (!static::lock($name)) continue
	$schedule = array_intersect_key((array)$task, array_flip(['every', 'daily', 'weekly']))
	$do = is_string($task->do) ? $task->do : null
	static::saveRun($name, $do, $schedule, static::fire($task->do))
	static::markRun($name, $now)
	static::unlock($name)
}
static

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

line 26
将运行数据保存到指定目录中的JSON文件,使用提供的名称、do、schedule和return值。
json_write(static::dir().$name.'.json', arr(do: $do, schedule: $schedule, return: $return))
static

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

line 28
根据任务的频率设置(如'每个'、'每日'或'每周')确定计划任务是否到期运行。它检查最后运行时间与当前时间的关系,以决定是否执行该任务。
$last = static::lastRun($name)
if (isset($task->every)){
	$every = preg_match('/^\d/', $task->every) ? $task->every : '1 '.$task->every
	$seconds = strtotime("+$every", 0) ?: 0
	return $seconds > 0 && ($now - $last) >= $seconds
}
if (isset($task->daily)){
	if (date('H:i', $now) !== $task->daily) return false
	return $last < strtotime('today 00:00', $now)
}
if (isset($task->weekly)){
	if (date('D H:i', $now) !== date('D H:i', strtotime($task->weekly, $now))) return false
	return $last < strtotime('monday this week', $now)
}
return false
static

tasks :: fire ($do)

line 46
执行由 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)

line 56
从文件中检索任务的最后运行时间戳,如果文件不存在则返回0。
$file = static::dir().$name.'.last'
return is_file($file) ? (int)file_get_contents($file) : 0
static

tasks :: markRun ($name, $ts)

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

tasks :: lock ($name)

line 63
如果任务的锁文件不存在或超过一个小时,则创建一个锁文件。
$file = static::dir().$name.'.lock'
if (is_file($file) && (time() - filemtime($file)) < 3600) return false
touch($file)
return true
static

tasks :: unlock ($name)

line 70
删除与任务相关的锁定文件,从而允许其再次执行。
@unlink(static::dir().$name.'.lock')
object

%useragent

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

%useragent -> source

line 9
该表达式从请求对象中获取用户代理字符串,如果未设置则返回null。
%req->userAgent ?: null
prop

%useragent -> os

line 11
通过将用户代理字符串与预定义模式进行匹配来确定操作系统。
if (!$this->source) return 'Unknown'
$list = [
	'Android' => '/Android/i',
	'iPadOS' => '/iPad.*OS/i',
	'iOS' => '/iPhone|iPod/i',
	'Windows' => '/Windows NT/i',
	'macOS' => '/Mac OS X/i',
	'ChromeOS' => '/CrOS/i',
	'Linux' => '/Linux/i',
]
foreach ($list AS $n => $r) if (preg_match($r, $this->source)) return $n
if (preg_match('/iPad/i',$this->source) && preg_match('/Mac OS X/i',$this->source)) return 'iPadOS'
return 'Unknown'
prop

%useragent -> osV

line 27
从用户代理字符串中提取操作系统版本(如果可用),并以清洁格式返回。
if (!$this->source) return void
if (preg_match('/(?:Android|OS X|OS|Windows NT)\s*([0-9._]+)/i', $this->source, $m)){
	$v = strtr($m[1], [us => dot])
	$v = preg_replace('/[^0-9.].*/', void, $v)
	$v = preg_replace('/(?:\.0)+$/', void, $v)
	return $v
}
return void
prop

%useragent -> osFull

line 38
返回完整的操作系统名称及其版本(如果可用);否则返回'未知'或仅操作系统名称。
if (!$this->OS) return 'Unknown'
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

line 48
根据提供的用户代理字符串确定网页浏览器的名称。它检查各种模式以识别流行的浏览器,如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

line 65
从用户代理字符串中提取版本号,如果匹配特定浏览器模式,则返回清理后的版本号或在未找到匹配时返回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

line 76
返回完整的用户代理字符串,包括用户代理的名称和版本,格式化以排除不必要的部分。
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

line 85
根据存储在 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
version 1.0
creator q-ai.nl
summary Visitor tracking via heartbeat
extends model
package analytics
frontend false
backend true
requires @payload @model token useragent
tags visitors analytics heartbeat tracking
static

visitors :: table

line 11
'visitors::$table' 指的是与 Phlo 中的 'visitors' 资源相关联的数据库表。
'visitors'
static

visitors :: columns

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

visitors :: history

line 14
检索访客数量的历史记录,按日期分组并计算独特的令牌和总访问次数。
static::records(columns: 'FROM_UNIXTIME(changed, "%Y-%m-%d") AS date,COUNT(DISTINCT token) AS visitors,COUNT(id) AS visits', group: 'date', order: 'date DESC')
static

visitors :: online

line 15
这获取在过去9秒内发生变化的独特在线访客的数量。
static::item(columns: 'COUNT(DISTINCT token)', where: 'changed >= (UNIX_TIMESTAMP() - 9)')
static

visitors :: lastHour

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

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

line 18
通过将用户代理字符串与预定义的正则表达式模式进行匹配,来判断访问者是否为机器人。
if (!$ua) return false
return (bool)preg_match('/bot|crawl|spider|slurp|baiduspider|facebookexternalhit|twitterbot|linkedinbot|curl|wget|python-requests|go-http-client|java\//i', $ua)
static

visitors :: parseReferrer (string $url):string

line 23
解析引用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 30
处理更新访客心跳信息的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 51
通过测量活跃时间并将心跳数据发送到服务器来跟踪页面上的用户活动,包括用户状态和可见性状态。
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
version 1.0
creator q-ai.nl
summary Server-side WebSocket handler via phloWS
advice Enable this class only when websockets are configured for the host
package realtime
frontend false
backend true
tags websocket realtime ws server
static

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

line 10
使用指定的主机、令牌和套接字参数建立WebSocket连接。
!function_exists('wsConnect') || wsConnect($wsHost, $wsToken, $wsSocket)
static

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

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

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

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

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

line 13
使用指定的主机、令牌和套接字关闭WebSocket连接。
function_exists('wsClose') && wsClose($wsHost, $wsToken, $wsSocket)
object

%WhatsApp

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

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

line 10
使用指定的URL和密钥初始化WhatsApp实例,确保URL以斜杠结尾。
$this->url = rtrim($url, slash).slash
static

WhatsApp :: channel ($channel)

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

%WhatsApp -> number ($contact)

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

%WhatsApp -> isGroup ($contact)

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

%WhatsApp -> status

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

%WhatsApp -> health

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

%WhatsApp -> qr

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

%WhatsApp -> disconnect

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

%WhatsApp -> read ($chat)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

%WhatsApp -> startTyping ($to)

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

%WhatsApp -> stopTyping ($to)

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

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

line 38
向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)

/phlo/resources/active.phlo line 8
为HTML元素生成类属性字符串,如果条件为真,则将'active'添加到指定的类列表中。
$cond || $classList ? ' class="'.$classList.($cond ? ($classList ? space : void).'active' : void).'"' : void
function

age(int $time)

/phlo/resources/age.phlo line 8
通过从当前时间中减去给定的时间来计算年龄。
time() - $time
function

age_human(int $age)

/phlo/resources/age.human.phlo line 9
根据给定的年龄(以秒为单位)计算可读的时间持续时间。
time_human(time() - $age)
function

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

/phlo/resources/apcu.phlo line 9
使用APCu缓存一个值,指定键和回调,允许设置持续时间和可选日志记录。
first($value = apcu_entry($key, $cb, $duration), $log && debug('C: '.(strlen($key) > 58 ? substr($key, 0, 55).'...' : $key).(is_array($value) ? ' ('.count($value).')' : (is_numeric($value) ? ":$value" : (is_string($value) ? ':string:'.strlen($value) : colon.gettype($value))))))
function

await(...$jobs)

/phlo/resources/await.phlo line 10
等待多个作业完成,处理它们的输出和错误,并返回结果。它管理子进程,并确保它们不会无限期阻塞。
	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 10
创建一个按钮元素,使用作为 props 传递的指定参数。
tag('button', ...$args)
function

camel(string $text)

/phlo/resources/camel.phlo line 8
通过将每个单词的首字母大写并去除空格,将给定字符串转换为驼峰式。
lcfirst(str_replace(space, void, ucwords(lcfirst($text))))
function

chunk(...$cmds):void

/phlo/resources/chunk.phlo line 9
此函数将数据块发送到客户端,如果尚未开始流,则初始化流并为响应设置适当的头部。
	$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, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES).lf)
	$cli || [@ob_flush(), flush()]
function

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

/phlo/resources/create.phlo line 8
通过使用可迭代对象中的值作为键,并使用可选的值回调来确定相应的值,创建一个关联数组。
array_combine(loop($items, $keyCb), $valueCb ? loop($items, $valueCb) : $items)
function

en($text, ...$args)

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

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

/phlo/resources/exec.stream.phlo line 9
在单独的进程中执行命令,并异步流式传输其输出和错误消息,以对象形式返回。它还支持超时功能,以在超过指定持续时间时终止进程。
	$desc = [0 => ['pipe','r'], 1 => ['pipe','w'], 2 => ['pipe','w']]
	$proc = proc_open($cmd, $desc, $pipes)
	if (!is_resource($proc)) return
	stream_set_blocking($pipes[1], false)
	stream_set_blocking($pipes[2], false)
	$bufOut = void
	$bufErr = void
	while (true){
		$status = proc_get_status($proc)
		$running = $status['running']
		$read = []
		$w = null
		$e = null
		if (!feof($pipes[1])) $read[] = $pipes[1]
		if (!feof($pipes[2])) $read[] = $pipes[2]
		if ($read) @stream_select($read, $w, $e, 0, 200000)
		foreach ($read AS $r){
			$chunk = fread($r, 8192)
			if ($chunk === void || $chunk === false) continue
			if ($r === $pipes[1]){
				$bufOut .= $chunk
				while (($pos = strpos($bufOut, lf)) !== false){
					$line = substr($bufOut, 0, $pos)
					$bufOut = substr($bufOut, $pos + 1)
					yield obj(data: $line)
				}
			}
			else {
				$bufErr .= $chunk
				while (($pos = strpos($bufErr, lf)) !== false){
					$line = substr($bufErr, 0, $pos)
					$bufErr = substr($bufErr, $pos + 1)
					yield obj(data: $line, error: true)
				}
			}
		}
		if (!$running) break
		if ($timeoutSec > 0 && ($status['running_time'] ?? 0) > $timeoutSec){
			proc_terminate($proc)
			yield obj(data: 'process timeout', error: true)
			break
		}
	}
	if ($bufOut !== void) yield obj(data: $bufOut)
	if ($bufErr !== void) yield obj(data: $bufErr, error: true)
	foreach ($pipes AS $p) @fclose($p)
	proc_close($proc)
function

HTTP(string $url, array $headers = [], bool $JSON = false, $POST = null, $PUT = null, $PATCH = null, $QUERY = null, bool $DELETE = false, string|bool|null $agent = null, string|bool $cookies = false, int $timeout = 15, &$response = null)

/phlo/resources/HTTP.phlo line 9
	$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 11
在视图中创建一个具有指定参数的输入元素。
tag('input', ...$args)
function

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

/phlo/resources/n8n.phlo line 9
向 n8n webhook 发送一个可选数据和测试标志的 HTTP POST 请求。
HTTP(%creds->n8n->server.'webhook'.($test ? '-test' : '').'/'.$webhook, POST: $data)
function

nl($text, ...$args)

/phlo/resources/lang.phlo line 11
使用指定的参数格式将给定文本翻译成荷兰语。
%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 8
通过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 240
根据提供的名称和参数创建或检索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 38
通过设置环境、加载必要的类和函数,并根据提供的参数配置各种运行时参数来初始化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)

/phlo/resources/phlo.async.phlo line 9
异步执行回调函数,传递任何附加参数,如果成功则返回进程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 190
处理命令行参数,调度目标命令,并以JSON格式打印结果。
if (!$args) return;
$target = array_shift($args);
$result = phlo_dispatch($target, $args);
if (isset($result)) print(json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES).lf);
function

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

/phlo/phlo.php line 177
根据提供的目标字符串,将方法调用分发到对象或类的静态方法,可选择性地传递参数。
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 33
通过将Throwable对象传递给phlo_error_handle函数来处理异常。
require_once engine.'error.php';
phlo_error_handle($e);
function

phlo_exists(string $obj)

/phlo/resources/phlo.exists.phlo line 8
检查指定的 PHP 文件是否存在于给定的对象路径中。
is_file(php.strtr($obj, [us => dot]).'.php')
function

phlo_load(bool $http):void

/phlo/phlo.php line 153
加载应用程序所需的运行时文件,确保应用程序仅加载一次,并为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 197
启动一个 Phlo 服务器,监听传入消息,处理这些消息并以 JSON 格式发送响应。它处理同步和异步请求,管理输出流和错误报告。
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], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES).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], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES).lf);
		}
		else $result = phlo_dispatch($target, $args);
		fwrite(STDOUT, json_encode(['id' => $id, 't' => 'done', 'result' => $result], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES).lf);
	}
	catch (Throwable $e){
		while (ob_get_level()) ob_end_clean();
		fwrite(STDOUT, json_encode(['id' => $id, 't' => 'error', 'message' => $e->getMessage()], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES).lf);
	}
	phlo('tech/reset');
	if (session_status() === PHP_SESSION_ACTIVE) session_write_close();
	gc_collect_cycles();
}
function

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

/phlo/resources/phlo.stream.phlo line 10
从回调函数流式输出,允许异步处理数据。它可以在守护进程模式下运行或在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 9
以同步方式执行回调函数,并将任何附加参数传递给它,返回结果作为 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 115
处理Phlo应用程序的主要执行流程,管理CLI和Web环境的请求,包括身份验证和仪表板渲染。
try {
	$req = phlo('req');
	if ($req->cli){
		$target = $req->args[0] ?? void;
		if (str_starts_with($target, 'build::') || str_starts_with($target, 'reflect::')){
			phlo_cli($req->args);
			return;
		}
		phlo_load(false);
		phlo('app');
		phlo_cli($req->args);
		return;
	}
	$isDashboard = build && debug && control && str_starts_with($req->path.slash, control.slash);
	if (auth && !$isDashboard){
		phlo_auth('site', 'Phlo App - '.host);
		if (phlo('res')->done) return;
	}
	if ($isDashboard){
		require_once engine.'control.php';
		phlo_dashboard::handle(substr($req->path, strlen(control) + 1));
		phlo('res')->render();
		return;
	}
	phlo_load(true);
	phlo('app');
	phlo('res')->render();
}
catch (RuntimeException $e){
	if ($e->getMessage() === 'PhloDump') return;
	phlo_exception($e);
}
catch (Throwable $e){
	phlo_exception($e);
}
function

select(...$args):string

/phlo/resources/tags.form.phlo line 12
创建一个 'select' HTML 元素,使用提供的参数作为属性和选项。
tag('select', ...$args)
function

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

/phlo/resources/settings.phlo line 10
	$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)

/phlo/resources/slug.phlo line 8
将给定字符串转换为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 10
	$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)

/phlo/resources/tag.phlo line 9
生成一个具有指定名称、可选内部内容和附加属性的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 13
创建一个带有指定参数的 'textarea' HTML 元素。
tag('textarea', ...$args)
function

time_human(?int $time = null)

/phlo/resources/time.human.phlo line 8
将给定的时间戳转换为人类可读的时间差格式,例如 '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, ...$data)

/phlo/resources/wsCast.phlo line 9
使用提供的主机和端口向指定目标或所有目标发送WebSocket消息。
HTTP (
	'http://127.0.0.1:'.$wsPort.'/message',
	JSON: true,
	POST: arr (
		host: $wsHost,
		target: $wsTarget,
		data: $data,
	),
)

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