yf_docs_api classAPI reference of the docs site (/docs/api): the framework classes and global helpers, read by reflection.
Only names from the index are looked up, and the index is built by scanning the framework directories (file name → class
name), so a request never picks the file or class to load. Classes are loaded (main()->load_class_file()), never instantiated.
| File | plugins/docs/classes/yf_docs_api.class.php |
|---|---|
| Use | _class('docs_api') |
A site sets them in its config with $PROJECT_CONF['docs_api']['NAME'] = ...;
| Property | Default | Description |
|---|---|---|
$CACHE_TTLint |
3600 |
Seconds the class index stays in the framework cache; 0 skips the framework cache |
$INDEX_FILE_DIRstring|null |
null |
Directory of the index file, used when the framework cache is off; null: STORAGE_PATH core_cache/, '' turns it off |
$KINDSarray |
['classes' => ['classes', 'class'], 'modules' => ['user modules', 'user module'], 'admin_modules' => ['admin modules', 'admin module']] |
Class directories inside a plugin, in index order => [label of the list, label of one class] |
$MAX_FILE_SOURCEint |
204800 |
Largest part of a file shown on the page of a class the docs do not load, in bytes |
$SECTION_ALIASESarray |
|
Old docs sections => API page id |
$SHOW_TAGSarray |
|
Docblock tags shown besides the summary, @param and @return |
static $_indexarray|null |
null |
The class index of this request |
public function show($id = '')
Route of /docs/api/<id>: the index, the global helpers or one class.
$id string API page id: '' (index), 'functions' or an id of the index public function show($id = '')
{
$id = is_string($id) ? $id : '';
if ($id === '') {
return $this->_index_page();
}
if ($id === 'functions') {
return $this->_functions_page();
}
$entry = $this->_find($id);
if ( ! $entry) {
return _404('Unknown class');
}
return $this->_class_page($entry);
}
_ hooks and helpers; not routable as page actionspublic function _build_index()
public function _build_index()
{
$dirs = ['core' => ['classes' => YF_PATH . 'classes/']];
foreach ((array) glob(YF_PATH . 'plugins/*', GLOB_ONLYDIR) as $plugin_dir) {
foreach (array_keys($this->KINDS) as $kind) {
if (is_dir($plugin_dir . '/' . $kind)) {
$dirs[basename($plugin_dir)][$kind] = $plugin_dir . '/' . $kind . '/';
}
}
}
$core = $dirs['core'];
unset($dirs['core']);
ksort($dirs);
$dirs = ['core' => $core] + $dirs;
$ext = '.class.php';
$groups = [];
$ids = [];
foreach ($dirs as $group => $kinds) {
foreach ($kinds as $kind => $dir) {
$files = array_merge((array) glob($dir . '*' . $ext), (array) glob($dir . '*/*' . $ext));
sort($files);
foreach ($files as $file) {
$class = substr(basename($file), 0, -strlen($ext));
$info = $this->_scan_class_file($file, $class);
// files that declare no class of their name (module aliases like modules/yf_api.class.php) cannot be reflected
if ($info === null) {
continue;
}
$id = $class;
if (isset($ids[$id])) {
$id = $class . '-' . ['classes' => 'class', 'modules' => 'module', 'admin_modules' => 'admin'][$kind];
}
$rel = substr($file, strlen(YF_PATH));
$sub = substr(dirname($file) . '/', strlen($dir));
$entry = [
'id' => $id,
'class' => $class,
'short' => str_starts_with($class, YF_PREFIX) ? substr($class, strlen(YF_PREFIX)) : $class,
'file' => $rel,
// the directory main()->load_class_file() takes: inside the plugin, or inside YF_PATH for core
'dir' => $kind . '/' . $sub,
'sub' => $sub,
'group' => $group,
'kind' => $kind,
'summary' => $info['summary'],
];
$groups[$group][$kind][$id] = $entry;
$ids[$id] = $entry;
}
}
}
return ['groups' => $groups, 'ids' => $ids];
}
public function _class_href($class, $file = '')
URL of the API page of a class name if it is in the index (the entry of that file, to tell a same-named module and admin module apart), else ''.
$class string$file string file of the class public function _class_href($class, $file = '')
{
$ids = $this->_get_index()['ids'];
$yf_path = (string) realpath(YF_PATH);
$real = $file !== '' ? (string) realpath($file) : '';
$rel = $real !== '' && str_starts_with($real, $yf_path) ? ltrim(substr($real, strlen($yf_path)), '/') : '';
foreach ([$class, $class . '-class', $class . '-module', $class . '-admin'] as $id) {
if (isset($ids[$id]) && ($rel === '' || $ids[$id]['file'] === $rel)) {
return $this->_url($id);
}
}
return '';
}
public function _class_link($class, $file = '')
$class string$file string public function _class_link($class, $file = '')
{
$href = $this->_class_href($class, $file);
return $href !== '' ? '<a href="' . $href . '"><code>' . $this->_e($class) . '</code></a>' : '<code>' . $this->_e($class) . '</code>';
}
public function _class_page($entry)
/docs/api/<id>: class docblock, public properties (the config surface), public methods with their source.
$entry array index entry public function _class_page($entry)
{
[$r, $notice] = $this->_reflect($entry);
$api = _class('core_api');
$doc = $this->_parse_doc($r ? $r->getDocComment() : '');
$vars = [
'class' => $this->_e($entry['class']),
'kind' => $this->_e($this->KINDS[$entry['kind']][1] ?? $entry['kind']),
'group' => $this->_e($entry['group']),
'group_link' => $this->_url() . '#g-' . $this->_e($entry['group']),
'index_link' => $this->_url(),
'file' => $this->_e($entry['file']),
'github' => _class('docs')->_github_btn(['file' => YF_PATH . $entry['file'], 'line_start' => $r ? $r->getStartLine() : 1]),
'usage' => $this->_e($this->_usage($entry)),
'conf_key' => $this->_e($entry['short']),
'notice' => $this->_e($notice),
'summary' => $this->_e(($doc['summary'] ?? null) ?: $entry['summary']),
'text' => nl2br($this->_e($doc['text'])),
'tags' => $this->_tags_html($doc['tags']),
'modifiers' => '',
'extends' => '',
'properties' => [],
'inherited_properties' => '',
'constants' => [],
'methods' => '',
'helper_methods' => '',
'inherited_methods' => '',
'method_count' => 0,
'file_source' => '',
'reflected' => $r ? 1 : '',
];
if ( ! $r) {
$source = (string) file_get_contents(YF_PATH . $entry['file'], false, null, 0, $this->MAX_FILE_SOURCE + 1);
if (strlen($source) > $this->MAX_FILE_SOURCE) {
$source = substr($source, 0, $this->MAX_FILE_SOURCE) . PHP_EOL . '/* ... cut at ' . round($this->MAX_FILE_SOURCE / 1024) . ' KB: the whole file is on GitHub */';
}
$vars['file_source'] = $this->_e($source);
return tpl()->parse('docs/api_class', $vars);
}
$vars['modifiers'] = $this->_e(trim(($r->isInterface() ? 'interface' : ($r->isTrait() ? 'trait' : '')) . ' ' . ($r->isAbstract() && ! $r->isInterface() ? 'abstract' : '') . ' ' . ($r->isFinal() ? 'final' : '')));
$parents = [];
for ($p = $r->getParentClass(); $p; $p = $p->getParentClass()) {
$parents[] = $this->_class_link($p->getName(), (string) $p->getFileName());
}
$vars['extends'] = implode(' → ', $parents);
foreach ($r->getReflectionConstants(ReflectionClassConstant::IS_PUBLIC) as $c) {
if ($c->getDeclaringClass()->getName() !== $r->getName()) {
continue;
}
$vars['constants'][$c->getName()] = [
'name' => $this->_e($c->getName()),
'value' => $this->_e($this->_redact($this->_export($c->getValue()))),
'desc' => $this->_e($this->_parse_doc($c->getDocComment())['summary']),
];
}
$inherited = [];
foreach ($r->getProperties(ReflectionProperty::IS_PUBLIC) as $p) {
$declaring = $p->getDeclaringClass();
if ($declaring->getName() !== $r->getName()) {
$inherited[$declaring->getName()][$p->getName()] = '$' . $p->getName();
continue;
}
$vars['properties'][$p->getName()] = $this->_property_row($p);
}
ksort($vars['properties']);
$vars['inherited_properties'] = $this->_inherited_html($inherited, 'p-');
$main = '';
$helpers = '';
$inherited = [];
$methods = $r->getMethods(ReflectionMethod::IS_PUBLIC);
usort($methods, fn ($a, $b) => strcmp($a->getName(), $b->getName()));
foreach ($methods as $m) {
$declaring = $m->getDeclaringClass();
if ($declaring->getName() !== $r->getName()) {
$inherited[$declaring->getName()][$m->getName()] = $m->getName() . '()';
continue;
}
$vars['method_count']++;
if ($m->getName()[0] === '_') {
$helpers .= $this->_member_html($m, 'm-');
} else {
$main .= $this->_member_html($m, 'm-');
}
}
$vars['methods'] = $main;
$vars['helper_methods'] = $helpers;
$vars['inherited_methods'] = $this->_inherited_html($inherited, 'm-');
return tpl()->parse('docs/api_class', $vars);
}
public function _declared($name)
$name string public function _declared($name)
{
return class_exists($name, false) || interface_exists($name, false) || trait_exists($name, false);
}
public function _default_expression($reflection_string)
The default of a property or parameter as written in the source when it is not a literal (constants, expressions): the evaluated value could show a path or a secret of this install. Read from the reflection string ("... = EXPR ]").
$reflection_string string (string) of a ReflectionProperty or ReflectionParameter public function _default_expression($reflection_string)
{
if ( ! preg_match('~ = (.*) \]\s*$~s', $reflection_string, $m)) {
return null;
}
$expr = trim($m[1]);
$code = preg_replace(['~\'(?:[^\'\\\\]|\\\\.)*\'|"(?:[^"\\\\]|\\\\.)*"~s', '~\b(?:true|false|null|array)\b~i'], '', $expr);
if ( ! preg_match('~[a-z_\\\\]~i', (string) $code)) {
return null;
}
return mb_strlen($expr) > 200 ? mb_substr($expr, 0, 197) . '...' : $expr;
}
public function _dependency_blocker($scan, $depth = 0)
The classes a scanned file needs first: its load() arguments (loaded here from the framework, so that load() then finds them declared and includes nothing), its parent, interfaces and traits; then the abstract and interface methods it must define (a missing one is a fatal error).
$scan array result of _scan_tokens()$depth int public function _dependency_blocker($scan, $depth = 0)
{
$needs = [];
foreach ($scan['loads'] as $name) {
$needs[] = str_starts_with(strtolower($name), 'yf_') ? $name : 'yf_' . $name;
}
$types = array_values(array_filter(array_merge([$scan['extends']], $scan['implements'], $scan['traits']), 'strlen'));
foreach (array_unique(array_merge($needs, $types)) as $name) {
if ($this->_declared($name)) {
continue;
}
$entry = $this->_entry_by_class($name);
if ( ! $entry) {
return 'it needs ' . $name . ', which is neither loaded nor a framework class';
}
[$r] = $this->_reflect($entry, $depth + 1);
if ( ! $r) {
return 'it needs ' . $name . ', which the docs do not load';
}
}
if ($scan['kind'] !== 'class' || $scan['abstract']) {
return '';
}
$have = array_flip($scan['methods']);
$required = [];
foreach ($types as $name) {
foreach ((new ReflectionClass($name))->getMethods() as $m) {
if ($m->isAbstract()) {
$required[strtolower($m->getName())] = $m->getName() . '()';
} else {
$have[strtolower($m->getName())] = true;
}
}
}
$missing = array_diff_key($required, $have);
return $missing ? 'it does not implement ' . implode(', ', $missing) . ' (loading it is a fatal error)' : '';
}
public function _e($text)
public function _e($text)
{
return _prepare_html((string) $text, 0);
}
public function _entry_by_class($class)
The validated entry of a class name (not of a same-named duplicate), or null.
$class string public function _entry_by_class($class)
{
return $this->_find(strtolower(ltrim((string) $class, '\\')));
}
public function _export($value, $depth = 0)
A default value as short PHP code: nested arrays and long strings are cut, objects show their class.
$depth int public function _export($value, $depth = 0)
{
if (is_array($value)) {
if ( ! $value) {
return '[]';
}
if ($depth > 1) {
return '[...]';
}
$is_list = array_is_list($value);
$parts = [];
foreach ($value as $k => $v) {
if (count($parts) >= 6) {
$parts[] = '... (' . count($value) . ' items)';
break;
}
$parts[] = ($is_list ? '' : var_export($k, true) . ' => ') . $this->_export($v, $depth + 1);
}
return '[' . implode(', ', $parts) . ']';
}
if (is_object($value)) {
return $value instanceof UnitEnum ? get_class($value) . '::' . $value->name : 'new ' . get_class($value);
}
if ($value === null) {
return 'null';
}
if (is_string($value) && mb_strlen($value) > 60) {
return var_export(mb_substr($value, 0, 57), true) . '...';
}
return var_export($value, true);
}
public function _find($id)
The index entry of a page id, or null: the only way from request input to a class name. A cached index is not trusted: the entry must name a framework class file that exists below YF_PATH.
public function _find($id)
{
if ( ! is_string($id) || ! preg_match('~^yf_[a-z0-9_]+(?:-(?:class|module|admin))?$~', $id)) {
return null;
}
$entry = $this->_get_index()['ids'][$id] ?? null;
return is_array($entry) && $this->_valid_entry($id, $entry) ? $entry : null;
}
public function _function_files()
Global helpers per file of YF_PATH functions/: [file name => [function name => ReflectionFunction]]. Files not loaded at this point (bootstrap and on-demand files) map to [].
public function _function_files()
{
$dir = realpath(YF_PATH . 'functions');
$files = [];
foreach ((array) glob(YF_PATH . 'functions/*.php') as $f) {
$files[basename($f)] = [];
}
ksort($files);
foreach (get_defined_functions()['user'] as $name) {
$r = new ReflectionFunction($name);
$file = (string) $r->getFileName();
if (dirname((string) realpath($file)) === $dir && isset($files[basename($file)])) {
$files[basename($file)][$r->getName()] = $r;
}
}
foreach ($files as &$funcs) {
ksort($funcs);
}
return $files;
}
public function _functions_page()
/docs/api/functions: each helper with its signature, docblock and source.
public function _functions_page()
{
$files = [];
foreach ($this->_function_files() as $name => $funcs) {
$items = '';
foreach ($funcs as $r) {
$items .= $this->_member_html($r, 'f-');
}
$files[$name] = [
'id' => 'file-' . $this->_e(basename($name, '.php')),
'name' => $this->_e($name),
'count' => count($funcs),
'github' => _class('docs')->_github_btn(['file' => YF_PATH . 'functions/' . $name, 'line_start' => 1]),
'items' => $items,
];
}
return tpl()->parse('docs/api_functions', [
'files' => $files,
'index_link' => $this->_url(),
]);
}
public function _get_index()
Class index: ['groups' => [group => [kind => [id => entry]]], 'ids' => [id => entry]], from a static property, the framework cache, the index file or a scan of YF_PATH classes/ and plugins/<p>/{classes,modules,admin_modules}/ (one subdirectory deep).
public function _get_index()
{
if (self::$_index !== null) {
return self::$_index;
}
$key = 'docs_api_index_' . $this->_index_key();
$valid = fn ($index) => is_array($index) && is_array($index['ids'] ?? null) && is_array($index['groups'] ?? null);
$index = $this->CACHE_TTL ? cache_get($key) : null;
if ( ! $valid($index)) {
$file = $this->_index_file($key);
$index = $file !== '' && is_file($file) ? json_decode((string) file_get_contents($file), true) : null;
if ( ! $valid($index)) {
$index = $this->_build_index();
$this->_write_index_file($file, $index);
}
$this->CACHE_TTL && cache_set($key, $index, $this->CACHE_TTL);
}
return self::$_index = $index;
}
public function _hook_side_column()
Side column of the API pages: the index, the helpers and one link per group (not every class).
public function _hook_side_column()
{
$links = [
['name' => 'API index', 'link' => $this->_url()],
['name' => 'Global helpers', 'link' => $this->_url('functions')],
];
foreach (array_keys($this->_get_index()['groups']) as $group) {
$links[] = [
'name' => $group === 'core' ? 'core' : $group,
'link' => $this->_url() . '#g-' . $group,
];
}
return html()->navlist($links);
}
public function _index_file($key)
Path of the index file of a key, or '' when it is turned off.
$key string public function _index_file($key)
{
$dir = $this->INDEX_FILE_DIR ?? (defined('STORAGE_PATH') ? STORAGE_PATH . 'core_cache/' : '');
return $dir !== '' ? rtrim($dir, '/') . '/' . $key . '.json' : '';
}
public function _index_key()
Changes when a class file is added, removed or renamed in the scanned directories (a deploy), and per framework copy.
public function _index_key()
{
$dirs = [YF_PATH . 'classes', YF_PATH . 'plugins'];
foreach (array_keys($this->KINDS) as $kind) {
foreach ((array) glob(YF_PATH . 'plugins/*/' . $kind, GLOB_ONLYDIR) as $dir) {
$dirs[] = $dir;
foreach ((array) glob($dir . '/*', GLOB_ONLYDIR) as $sub) {
$dirs[] = $sub;
}
}
}
$stamp = [];
foreach ($dirs as $dir) {
$stamp[] = $dir . ':' . (is_dir($dir) ? filemtime($dir) : 0);
}
return substr(md5(YF_PATH . '|' . implode('|', $stamp)), 0, 16);
}
public function _index_page()
/docs/api: the classes by plugin and the global helper files, with a client-side filter.
public function _index_page()
{
$groups = [];
$total = 0;
foreach ($this->_get_index()['groups'] as $group => $kinds) {
$lists = '';
$count = 0;
foreach ($kinds as $kind => $entries) {
$items = '';
foreach ($entries as $entry) {
$items .= '<li class="docs-api-item" data-name="' . $this->_e(strtolower($entry['class'] . ' ' . $entry['summary'])) . '">'
. '<a href="' . $this->_url($entry['id']) . '"><code>' . $this->_e($entry['class']) . '</code></a>'
. ($entry['sub'] !== '' ? ' <small class="text-muted">' . $this->_e($entry['sub']) . '</small>' : '')
. ($entry['summary'] !== '' ? '<br><small class="text-muted">' . $this->_e($entry['summary']) . '</small>' : '')
. '</li>';
$count++;
}
$lists .= '<h5 class="docs-api-kind">' . $this->_e($this->KINDS[$kind][0] ?? $kind) . '</h5><ul class="list-unstyled docs-api-list">' . $items . '</ul>';
}
$total += $count;
$groups[$group] = [
'id' => 'g-' . $this->_e($group),
'title' => $group === 'core' ? 'Core <small>classes/</small>' : $this->_e($group) . ' <small>plugins/' . $this->_e($group) . '/</small>',
'count' => $count,
'lists' => $lists,
];
}
$function_files = [];
foreach ($this->_function_files() as $name => $funcs) {
$function_files[$name] = [
'name' => $this->_e($name),
'link' => $this->_url('functions') . '#file-' . $this->_e(basename($name, '.php')),
'count' => count($funcs),
];
}
return tpl()->parse('docs/api_index', [
'groups' => $groups,
'total' => $total,
'function_files' => $function_files,
'functions_link' => $this->_url('functions'),
]);
}
public function _inherited_html($inherited, $anchor_prefix)
Public members a class inherits, per declaring class, linked to that class's page.
$inherited array [declaring class => [name => label]]$anchor_prefix string public function _inherited_html($inherited, $anchor_prefix)
{
$out = '';
foreach ($inherited as $class => $names) {
ksort($names);
$file = (string) (new ReflectionClass($class))->getFileName();
$href = $this->_class_href($class, $file);
$links = [];
foreach ($names as $name => $label) {
$code = '<code>' . $this->_e($label) . '</code>';
$links[] = $href !== '' ? '<a href="' . $href . '#' . $anchor_prefix . $this->_e($name) . '">' . $code . '</a>' : $code;
}
$out .= '<p class="docs-api-inherited"><small class="text-muted">from</small> ' . $this->_class_link($class, $file) . ': ' . implode(', ', $links) . '</p>';
}
return $out;
}
public function _load_blocker($entry, $depth = 0)
Why the file of an entry must not be included, or ''.
$entry array$depth int public function _load_blocker($entry, $depth = 0)
{
if ($entry['id'] !== $entry['class']) {
// a same-named admin module would replace the user module of that name for the rest of the page
return 'the class of the same name in ' . ($this->_find($entry['class'])['file'] ?? 'another file') . ' is the one this site section loads';
}
if ($depth > 10) {
return 'it needs classes too many levels deep';
}
$scan = $this->_scan_tokens(YF_PATH . $entry['file'], $entry['class']);
return $scan['blocker'] !== '' ? $scan['blocker'] : $this->_dependency_blocker($scan, $depth);
}
public function _member_html($f, $anchor_prefix)
One method or function: anchor, signature, docblock summary, @param / @return, GitHub link and the source, collapsed.
$f ReflectionFunctionAbstract$anchor_prefix string m- (methods) or f- (functions) public function _member_html($f, $anchor_prefix)
{
$doc = $this->_parse_doc($f->getDocComment());
$params = '';
foreach ($f->getParameters() as $p) {
$d = $doc['params'][$p->getName()] ?? ['type' => '', 'desc' => ''];
$type = $p->hasType() ? (string) $p->getType() : $d['type'];
if ($d['desc'] === '' && ($type === '' || str_contains($type, 'mixed'))) {
continue;
}
$params .= '<li><code>$' . $this->_e($p->getName()) . '</code>' . ($type !== '' ? ' <small class="text-muted">' . $this->_e($type) . '</small>' : '') . ($d['desc'] !== '' ? ' ' . $this->_e($d['desc']) : '') . '</li>';
}
$return = '';
if ($doc['return'] && ($doc['return']['desc'] !== '' || ($doc['return']['type'] !== '' && ! str_contains($doc['return']['type'], 'mixed')))) {
$return = '<small class="text-muted">' . $this->_e($doc['return']['type']) . '</small> ' . $this->_e($doc['return']['desc']);
}
$file = (string) $f->getFileName();
$info = ['file' => $file, 'line_start' => $f->getStartLine()];
$source = $file !== '' ? _class('core_api')->get_file_slice($file, $f->getStartLine(), $f->getEndLine()) : '';
$anchor = $anchor_prefix . $f->getName();
return tpl()->parse('docs/api_member', [
'anchor' => $this->_e($anchor),
'name' => $this->_e($f->getName()),
'signature' => $this->_e($this->_signature($f)),
'summary' => $this->_e($doc['summary']),
'text' => nl2br($this->_e($doc['text'])),
'params' => $params,
'return' => $return,
'tags' => $this->_tags_html($doc['tags']),
'github' => $file !== '' ? _class('docs')->_github_btn($info) : '',
'line' => (int) $f->getStartLine(),
'source' => $this->_e($source),
'source_id' => 'src-' . $this->_e($anchor),
]);
}
public function _parse_doc($doc)
Docblock parts: summary (first paragraph), text (the rest), params [name => [type, desc]], return and var [type, desc], other tags [[name, value]].
public function _parse_doc($doc)
{
$out = ['summary' => '', 'text' => '', 'params' => [], 'return' => null, 'var' => null, 'tags' => []];
$doc = trim((string) $doc);
if ( ! str_starts_with($doc, '/**')) {
return $out;
}
$doc = preg_replace(['~^/\*\*~', '~\*/$~'], '', $doc);
$text = [];
$tags = [];
foreach (preg_split('~\R~', $doc) as $line) {
$line = preg_replace('~^\s*\*(?: |\t|$)~', '', $line);
if (preg_match('~^\s*@([a-zA-Z][\w-]*)\s*(.*)$~', $line, $m)) {
$tags[] = [$m[1], trim($m[2])];
} elseif ($tags) {
if (trim($line) !== '') {
$tags[count($tags) - 1][1] .= ' ' . trim($line);
}
} else {
$text[] = rtrim($line);
}
}
$paras = preg_split('~\n\s*\n~', trim(implode("\n", $text)), 2);
$out['summary'] = trim(preg_replace('~\s+~', ' ', $paras[0]));
$out['text'] = trim($paras[1] ?? '');
foreach ($tags as [$name, $value]) {
if ($name === 'param' && preg_match('~^(?:([^\s$&.]\S*)\s+)?&?\s*(?:\.\.\.)?\$(\w+)\s*(.*)$~s', $value, $m)) {
$out['params'][$m[2]] = ['type' => $m[1], 'desc' => trim($m[3])];
} elseif ($name === 'return' || $name === 'var') {
[$type, $desc] = preg_split('~\s+~', $value, 2) + [0 => '', 1 => ''];
$out[$name] = ['type' => $type, 'desc' => trim(preg_replace('~^\$\w+\s*~', '', $desc))];
} else {
$out['tags'][] = [$name, $value];
}
}
return $out;
}
public function _property_row($p)
$p ReflectionProperty public function _property_row($p)
{
$doc = $this->_parse_doc($p->getDocComment());
$default = '';
$full = '';
$expr = $this->_default_expression((string) $p);
if ($expr !== null) {
$default = $expr;
} elseif ($p->hasDefaultValue()) {
try {
$value = $p->getDefaultValue();
$default = $this->_redact($this->_export($value));
// the whole value when the short one is cut
if (is_array($value) && str_contains($default, '...')) {
$full = $this->_redact(substr(var_export($value, true), 0, 20000));
}
} catch (Throwable $e) {
$default = '(constant expression)';
}
}
$type = $p->hasType() ? (string) $p->getType() : (string) ($doc['var']['type'] ?? '');
return [
'name' => $this->_e($p->getName()),
'static' => $p->isStatic() ? 'static ' : '',
'type' => $this->_e($type),
'default' => $this->_e($default),
'full' => $this->_e($full),
'desc' => $this->_e(trim(($doc['var']['desc'] ?? '') !== '' ? $doc['var']['desc'] : $doc['summary'])),
];
}
public function _redact($code)
Paths of this install and values of secret-looking constants in an exported value become the constant names.
$code string public function _redact($code)
{
$paths = ['YF_PATH', 'APP_PATH', 'PROJECT_PATH', 'INCLUDE_PATH', 'SITE_PATH', 'ADMIN_SITE_PATH', 'STORAGE_PATH', 'CONFIG_PATH', 'LOGS_PATH', 'UPLOADS_PATH'];
$consts = [];
foreach ((array) (get_defined_constants(true)['user'] ?? []) as $name => $value) {
if (is_string($value) && strlen($value) >= 4 && (in_array($name, $paths, true) || preg_match('~PASS|PSWD|PWD|SECRET|KEY|TOKEN|SALT~i', $name))) {
$consts[addcslashes($value, '\'\\')] = $name;
}
}
uksort($consts, fn ($a, $b) => strlen((string) $b) <=> strlen((string) $a));
foreach ($consts as $value => $name) {
$code = str_replace((string) $value, '\' . ' . $name . ' . \'', $code);
}
return str_replace(['\'\' . ', ' . \'\''], '', $code);
}
public function _reflect($entry, $depth = 0)
Load the class of an index entry and reflect it. Only the validated framework file is included, never a site or project override (the override-aware loader is not used), after the classes it needs, and only when its top level holds nothing but declarations and load() calls; nothing is instantiated. Returns [ReflectionClass|null, notice text].
$entry array$depth int level of a needed class, against loops public function _reflect($entry, $depth = 0)
{
$class = $entry['class'];
try {
if ( ! $this->_declared($class)) {
$blocker = $this->_load_blocker($entry, $depth);
if ($blocker !== '') {
return [null, 'Not loaded by the docs: ' . $blocker . '. Read the source instead.'];
}
main()->include_module((string) realpath(YF_PATH . $entry['file']));
}
if ( ! $this->_declared($class)) {
return [null, 'The file did not declare the class in this request (it declares it under a condition).'];
}
$r = new ReflectionClass($class);
} catch (Throwable $e) {
// the reason goes to the log, the visitor gets a fixed text
logger()->warning('docs api: ' . $class . ' could not be loaded: ' . $e->getMessage());
return [null, 'The class could not be loaded here.'];
}
if (realpath((string) $r->getFileName()) !== realpath(YF_PATH . $entry['file'])) {
return [null, 'Another class of the same name (' . ltrim(substr((string) realpath((string) $r->getFileName()), strlen((string) realpath(YF_PATH))), '/') . ') is loaded in this request.'];
}
return [$r, ''];
}
public function _scan_class_file($file, $class)
Whether $file declares $class, and the summary of its docblock, without loading it.
$file string$class string public function _scan_class_file($file, $class)
{
if ( ! preg_match('~^[a-z_][a-z0-9_]*$~i', $class)) {
return null;
}
$src = (string) file_get_contents($file);
if ( ! preg_match('~^[ \t]*(?:(?:abstract|final|readonly)\s+)*(?:class|interface|trait)\s+' . $class . '\b~mi', $src, $m, PREG_OFFSET_CAPTURE)) {
return null;
}
$head = substr($src, 0, $m[0][1]);
$doc = '';
$start = strrpos($head, '/**');
$end = $start !== false ? strpos($head, '*/', $start) : false;
// the docblock reflection sees: only load() calls may stand between it and the class
if ($end !== false && preg_match('~^\s*(?:load\([^;]*\);\s*)*$~', substr($head, $end + 2))) {
$doc = substr($head, $start, $end + 2 - $start);
}
$summary = $this->_parse_doc($doc)['summary'];
if (mb_strlen($summary) > 160) {
$summary = rtrim(mb_substr($summary, 0, 157)) . '...';
}
return ['summary' => $summary];
}
public function _scan_statements($t, &$i, $class, &$out, $in_block)
Top-level statements of _scan_tokens(), or the statements of a guarded block ($in_block, up to its closing brace). Returns '' or the blocker.
$t PhpToken[] tokens without whitespace and comments$i int position, moved past what was read$class string$out array$in_block bool public function _scan_statements($t, &$i, $class, &$out, $in_block)
{
$n = count($t);
$text = fn ($k) => isset($t[$k]) ? $t[$k]->text : '';
$is = fn ($k, $id) => isset($t[$k]) && $t[$k]->is($id);
$names = [T_STRING, T_NAME_QUALIFIED, T_NAME_FULLY_QUALIFIED];
$mods = [];
while ($i < $n) {
$tok = $t[$i];
$code = 'it runs code outside its class (' . substr($tok->text, 0, 30) . ' on line ' . $tok->line . ')';
if ($mods && ! $tok->is([T_ABSTRACT, T_FINAL, T_READONLY, T_CLASS])) {
return $code;
}
if ($in_block && $tok->text === '}') {
$i++;
return '';
}
if ($tok->text === ';') {
$i++;
} elseif ($tok->is(T_CLOSE_TAG)) {
// only whitespace may follow the close tag
for ($j = $i + 1; $j < $n; $j++) {
if ( ! $t[$j]->is(T_INLINE_HTML) || trim($t[$j]->text) !== '') {
return 'it runs code outside its class (' . substr($t[$j]->text, 0, 30) . ' on line ' . $t[$j]->line . ')';
}
}
return $in_block ? $code : '';
} elseif ($tok->is([T_DECLARE, T_USE, T_CONST])) {
for ($i++; $i < $n && $t[$i]->text !== ';'; $i++) {
// new in a constant initializer runs a constructor
if ($t[$i]->text === '{' || $t[$i]->is([T_CURLY_OPEN, T_DOLLAR_OPEN_CURLY_BRACES, T_NEW])) {
return $code;
}
}
$i++;
} elseif ($tok->is(T_ATTRIBUTE)) {
// #[...] before a declaration: not evaluated when the file is included
for ($depth = 0; $i < $n; $i++) {
if ($t[$i]->is(T_ATTRIBUTE) || $t[$i]->text === '[') {
$depth++;
} elseif ($t[$i]->text === ']' && --$depth === 0) {
break;
}
}
$i++;
} elseif ($tok->is([T_ABSTRACT, T_FINAL, T_READONLY])) {
$mods[] = $tok->id;
$i++;
} elseif ($tok->is([T_CLASS, T_INTERFACE, T_TRAIT, T_ENUM])) {
$kind = strtolower($tok->text);
if ( ! $is($i + 1, T_STRING)) {
return $code;
}
$target = strcasecmp($text($i + 1), $class) === 0;
$header = ['extends' => [], 'implements' => []];
$list = null;
for ($i += 2; $i < $n && $t[$i]->text !== '{'; $i++) {
$h = $t[$i];
if ($h->is(T_EXTENDS) || $h->is(T_IMPLEMENTS)) {
$list = $h->is(T_EXTENDS) ? 'extends' : 'implements';
} elseif ($list && $h->is($names)) {
$header[$list][] = ltrim($h->text, '\\');
} elseif ( ! ($list && $h->text === ',') && ! ($kind === 'enum' && ($h->text === ':' || $h->is(T_STRING)))) {
return $code;
}
}
$methods = [];
$traits = [];
if ( ! $this->_skip_body($t, $i, $methods, $traits)) {
return $code;
}
if ($target) {
$out['declared'] = true;
$out['kind'] = $kind;
$out['abstract'] = in_array(T_ABSTRACT, $mods, true);
$out['extends'] = $kind === 'class' ? (string) ($header['extends'][0] ?? '') : '';
$out['implements'] = $kind === 'class' ? $header['implements'] : array_merge($header['extends'], $header['implements']);
$out['traits'] = $traits;
$out['methods'] = $methods;
}
$mods = [];
} elseif ($tok->is(T_FUNCTION)) {
$j = $text($i + 1) === '&' ? $i + 2 : $i + 1;
if ( ! $is($j, T_STRING)) {
return $code;
}
for ($i = $j; $i < $n && $t[$i]->text !== '{'; $i++);
$none = [];
if ( ! $this->_skip_body($t, $i, $none, $none)) {
return $code;
}
} elseif ($tok->is(T_IF)) {
// if ([!] guard('literal'[, true|false])) {
$j = $i + 1;
$ok = $text($j++) === '(';
$text($j) === '!' && $j++;
$ok = $ok && $is($j, T_STRING) && in_array(strtolower($text($j)), ['class_exists', 'interface_exists', 'trait_exists', 'function_exists', 'defined'], true)
&& $text($j + 1) === '(' && $is($j + 2, T_CONSTANT_ENCAPSED_STRING);
$j += 3;
if ($ok && $text($j) === ',') {
$ok = in_array(strtolower($text($j + 1)), ['true', 'false'], true);
$j += 2;
}
if ( ! $ok || $text($j) !== ')' || $text($j + 1) !== ')' || $text($j + 2) !== '{') {
return $code;
}
$i = $j + 3;
$inner = $this->_scan_statements($t, $i, $class, $out, true);
if ($inner !== '') {
return $inner;
}
if ($is($i, T_ELSE) || $is($i, T_ELSEIF)) {
return 'it runs code outside its class (else on line ' . $t[$i]->line . ')';
}
} elseif ($tok->is(T_STRING) && strtolower($tok->text) === 'load' && $text($i + 1) === '(') {
// load('name'[, 'storage'[, 'dir/']]) with literal arguments only
$args = [];
for ($j = $i + 2; $is($j, T_CONSTANT_ENCAPSED_STRING); $j++) {
$args[] = substr($t[$j]->text, 1, -1);
if ($text($j + 1) !== ',') {
$j++;
break;
}
$j++;
}
if ( ! $args || ! preg_match('~^[a-z0-9_]+$~i', $args[0]) || $text($j) !== ')' || $text($j + 1) !== ';') {
return $code;
}
$out['loads'][] = $args[0];
$i = $j + 2;
} else {
return $code;
}
}
return $in_block ? 'the file has a block that is not closed' : '';
}
public function _scan_tokens($file, $class)
Allow-list scan of a class file: outside class and function bodies only declare / use / const statements, attributes, class, interface, trait, enum and function declarations, load('name', ...) calls with literal arguments and blocks guarded by if ([!] class_exists|interface_exists|trait_exists|function_exists|defined('literal'[, bool])) may appear; anything else sets 'blocker'. Also returns what $class declares: kind, abstract, parent, interfaces, traits and method names (lower case).
$file string$class string public function _scan_tokens($file, $class)
{
$out = ['blocker' => '', 'declared' => false, 'kind' => '', 'loads' => [], 'abstract' => false, 'extends' => '', 'implements' => [], 'traits' => [], 'methods' => []];
$all = PhpToken::tokenize((string) file_get_contents($file));
if ( ! $all || ! $all[0]->is(T_OPEN_TAG)) {
$out['blocker'] = 'the file does not start with <?php';
return $out;
}
$tokens = array_values(array_filter($all, fn ($t) => ! $t->isIgnorable()));
$i = 0;
$out['blocker'] = $this->_scan_statements($tokens, $i, $class, $out, false);
if ($out['blocker'] === '' && ! $out['declared']) {
$out['blocker'] = 'the file does not declare ' . $class;
}
return $out;
}
public function _signature($f)
Signature as PHP code: modifiers, name, typed parameters with defaults, return type.
$f ReflectionFunctionAbstract public function _signature($f)
{
$params = [];
foreach ($f->getParameters() as $p) {
$s = $p->hasType() ? (string) $p->getType() . ' ' : '';
$s .= ($p->isPassedByReference() ? '&' : '') . ($p->isVariadic() ? '...' : '') . '$' . $p->getName();
if ($p->isDefaultValueAvailable()) {
try {
$s .= ' = ' . ($this->_default_expression((string) $p) ?? $this->_redact($this->_export($p->getDefaultValue())));
} catch (Throwable $e) {
$s .= ' = ...';
}
}
$params[] = $s;
}
$prefix = 'function ';
if ($f instanceof ReflectionMethod) {
$prefix = ($f->isAbstract() ? 'abstract ' : '') . ($f->isFinal() ? 'final ' : '') . 'public ' . ($f->isStatic() ? 'static ' : '') . $prefix;
}
return $prefix . ($f->returnsReference() ? '&' : '') . $f->getName() . '(' . implode(', ', $params) . ')' . ($f->hasReturnType() ? ': ' . $f->getReturnType() : '');
}
public function _skip_body($t, &$i, &$methods, &$traits)
Skip a body that starts at $t[$i] ('{'), collecting the method names and used traits at its first level. Moves $i past the closing brace; false when there is no balanced body.
$t PhpToken[]$i int$methods array$traits array public function _skip_body($t, &$i, &$methods, &$traits)
{
$n = count($t);
if (($t[$i]->text ?? '') !== '{') {
return false;
}
$depth = 0;
for (; $i < $n; $i++) {
$tok = $t[$i];
if ($tok->text === '{' || $tok->is([T_CURLY_OPEN, T_DOLLAR_OPEN_CURLY_BRACES])) {
$depth++;
} elseif ($tok->text === '}') {
if (--$depth === 0) {
$i++;
return true;
}
} elseif ($depth === 1 && $tok->is(T_FUNCTION)) {
$j = ($t[$i + 1]->text ?? '') === '&' ? $i + 2 : $i + 1;
isset($t[$j]) && $methods[] = strtolower($t[$j]->text);
} elseif ($depth === 1 && $tok->is(T_USE)) {
for ($j = $i + 1; isset($t[$j]) && ! in_array($t[$j]->text, [';', '{'], true); $j++) {
$t[$j]->is([T_STRING, T_NAME_QUALIFIED, T_NAME_FULLY_QUALIFIED]) && $traits[] = ltrim($t[$j]->text, '\\');
}
}
}
return false;
}
public function _tags_html($tags)
$tags array [[name, value]]public function _url($id = '')
$id string API page id, already from the index public function _url($id = '')
{
return url('/@object/api' . ($id !== '' ? '/' . rawurlencode($id) : ''));
}
public function _usage($entry)
How a site reaches the class: _class() for classes (with the subdirectory), module() / the URL for modules.
$entry array public function _usage($entry)
{
if ($entry['kind'] === 'modules') {
return 'module(\'' . $entry['short'] . '\'), URL ?object=' . $entry['short'];
}
if ($entry['kind'] === 'admin_modules') {
return 'admin URL ?object=' . $entry['short'];
}
return '_class(\'' . $entry['short'] . '\'' . ($entry['sub'] !== '' ? ', \'classes/' . $entry['sub'] . '\'' : '') . ')';
}
public function _valid_entry($id, $entry)
$id string$entry array public function _valid_entry($id, $entry)
{
$class = (string) ($entry['class'] ?? '');
$file = (string) ($entry['file'] ?? '');
$kind = (string) ($entry['kind'] ?? '');
$sub = (string) ($entry['sub'] ?? '');
if (($entry['id'] ?? null) !== $id || ! preg_match('~^yf_[a-z0-9_]+$~', $class) || ($id !== $class && ! str_starts_with($id, $class . '-'))
|| ! isset($this->KINDS[$kind]) || ! preg_match('~^(?:[a-z0-9_]+/)?$~', $sub)
|| ! preg_match('~^(?:classes|plugins/[a-z0-9_]+/' . $kind . ')/' . preg_quote($sub . $class, '~') . '\.class\.php$~', $file)
|| ($entry['short'] ?? null) !== substr($class, strlen('yf_')) || ($entry['dir'] ?? null) !== $kind . '/' . $sub) {
return false;
}
$real = realpath(YF_PATH . $file);
$root = realpath(YF_PATH);
return $real !== false && $root !== false && is_file($real) && str_starts_with($real, $root . DIRECTORY_SEPARATOR);
}
public function _write_index_file($file, $index)
JSON, not PHP: the file is data, and _find() validates every entry read from it. Files of older keys are removed.
$file string$index array public function _write_index_file($file, $index)
{
if ($file === '') {
return false;
}
$dir = dirname($file);
if (( ! is_dir($dir) && ! @mkdir($dir, 0755, true) && ! is_dir($dir)) || ! is_writable($dir)) {
return false;
}
foreach ((array) glob($dir . '/docs_api_index_*.json') as $old) {
$old !== $file && unlink($old);
}
$tmp = $file . '.' . getmypid() . '.tmp';
return file_put_contents($tmp, (string) json_encode($index)) !== false && rename($tmp, $file);
}