yf_docs_api class

API 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.

Fileplugins/docs/classes/yf_docs_api.class.php
Use_class('docs_api')

Public properties

A site sets them in its config with $PROJECT_CONF['docs_api']['NAME'] = ...;

PropertyDefaultDescription
$CACHE_TTL
int
3600 Seconds the class index stays in the framework cache; 0 skips the framework cache
$INDEX_FILE_DIR
string|null
null Directory of the index file, used when the framework cache is off; null: STORAGE_PATH core_cache/, '' turns it off
$KINDS
array
['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_SOURCE
int
204800 Largest part of a file shown on the page of a class the docs do not load, in bytes
$SECTION_ALIASES
array
['common' => 'yf_common', 'db' => 'yf_db', 'db_migrator' => 'yf_db_migrator', 'db_query_builder' => 'yf_db_query_builder_driver', 'db_utils' => 'yf_db_utils_driver', 'dir' => 'yf_dir', ... (12 items)]
array (
  'common' => 'yf_common',
  'db' => 'yf_db',
  'db_migrator' => 'yf_db_migrator',
  'db_query_builder' => 'yf_db_query_builder_driver',
  'db_utils' => 'yf_db_utils_driver',
  'dir' => 'yf_dir',
  'functions' => 'functions',
  'graphics' => 'yf_graphics',
  'main' => 'yf_main',
  'model' => 'yf_model',
  'utils' => 'yf_utils',
  'validate' => 'yf_validate',
)
Old docs sections => API page id
$SHOW_TAGS
array
['deprecated', 'see', 'example', 'security', 'since', 'throws', ... (8 items)]
array (
  0 => 'deprecated',
  1 => 'see',
  2 => 'example',
  3 => 'security',
  4 => 'since',
  5 => 'throws',
  6 => 'link',
  7 => 'todo',
)
Docblock tags shown besides the summary, @param and @return
static $_index
array|null
null The class index of this request

Public methods 36

show

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);
    }

Methods starting with _ hooks and helpers; not routable as page actions

_build_index

public 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];
    }

_class_href

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 '';
    }

_class_page

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);
    }

_declared

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)' : '';
    }

_e

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, '\\')));
    }

_export

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);
    }

_find

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(),
        ]);
    }

_get_index

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);
    }

_index_file

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' : '';
    }

_index_key

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);
    }

_index_page

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;
    }

_load_blocker

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);
    }

_member_html

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),
        ]);
    }

_parse_doc

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;
    }

_property_row

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'])),
        ];
    }

_redact

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);
    }

_reflect

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' : '';
    }

_scan_tokens

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;
    }

_signature

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() : '');
    }

_skip_body

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;
    }

_tags_html

public function _tags_html($tags)
  • $tags array [[name, value]]
    public function _tags_html($tags)
    {
        $out = '';
        foreach ((array) $tags as [$name, $value]) {
            if ( ! in_array($name, $this->SHOW_TAGS, true)) {
                continue;
            }
            $label = $name === 'deprecated' ? 'label-danger text-bg-danger' : 'label-default text-bg-secondary';
            $out .= '<div class="docs-api-tag"><span class="label badge ' . $label . '">@' . $this->_e($name) . '</span> ' . $this->_e($value) . '</div>';
        }
        return $out;
    }

_url

public function _url($id = '')
  • $id string API page id, already from the index
    public function _url($id = '')
    {
        return url('/@object/api' . ($id !== '' ? '/' . rawurlencode($id) : ''));
    }

_usage

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'] . '\'' : '') . ')';
    }

_valid_entry

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);
    }