block. // Missing a formatter reduces presentation; it never hides the content. // // OPERATIONAL SAFEGUARDS // Paths are contained to SCRIPTS_DIR. // $rel is resolved with realpath() and required to remain under SCRIPTS_DIR, be a // regular file, and carry a .md extension. This is deliberate defence for a parameter // that will arrive from a request the moment this is wired up — without it, a // traversal sequence reaches any file the web user can read. // // Markdown is rendered in safe mode. // Parsedown runs with setSafeMode(true), and the
 fallback escapes everything.
//       These files are trusted today, but they are also synced between hosts.
//
//   Substituted conf values are escaped.
//       htmlspecialchars() is applied to both the value and the variable name, so a conf
//       value containing markup cannot inject into the rendered page.
//
//   Read-only. Discovers and renders; never writes a doc.
//
// EXPORTS
//   vv_docs_tree()     every *.md under SCRIPTS_DIR, relative paths, sorted
//   vv_docs_render()   one file to HTML with conf substitution applied
//
// CONFIGURATION
//   SCRIPTS_DIR       the containment root and the discovery root
//   PARSEDOWN_PATH    /usr/local/emhttp/plugins/varaverk/lib/Parsedown.php — optional
// ═══════════════════════════════════════════════════════════════════════════════════════════════

require_once __DIR__ . '/config.php';

define('PARSEDOWN_PATH', '/usr/local/emhttp/plugins/varaverk/lib/Parsedown.php');

function vv_docs_tree(): array {
    $base  = SCRIPTS_DIR;
    $tree  = [];
    $files = new RecursiveIteratorIterator(
        new RecursiveDirectoryIterator($base, FilesystemIterator::SKIP_DOTS),
        RecursiveIteratorIterator::SELF_FIRST
    );
    foreach ($files as $f) {
        if ($f->isFile() && strtolower($f->getExtension()) === 'md') {
            $rel    = ltrim(str_replace($base, '', $f->getPathname()), '/');
            $tree[] = $rel;
        }
    }
    sort($tree);
    return $tree;
}

function vv_docs_render(string $rel, array $vars): string {
    // Containment check — $rel is expected to come from a request parameter once this is
    // wired to a page. Resolve it and require the result to stay inside SCRIPTS_DIR and to
    // still be a .md file, so a traversal sequence cannot reach arbitrary files.
    $base = realpath(SCRIPTS_DIR);
    $path = realpath(SCRIPTS_DIR . '/' . $rel);
    if ($base === false || $path === false)             return '

File not found.

'; if (!str_starts_with($path, $base . '/')) return '

File not found.

'; if (strtolower(pathinfo($path, PATHINFO_EXTENSION)) !== 'md') return '

File not found.

'; if (!is_file($path)) return '

File not found.

'; return vv_docs_markdown(file_get_contents($path), $vars); } // Markdown → HTML for the subset these docs actually use: headings, paragraphs, bullet and // ordered lists, tables, blockquotes, fenced code, horizontal rules, and inline bold / italic / // code / links. // // Hand-rolled rather than vendored. Parsedown was the original plan and PARSEDOWN_PATH is still // honoured below if the file ever appears, but it has never been present on this system, so the // only path this function ever took was a
 dump of raw markdown — which is not a document,
// it is the source of one. A renderer for a subset we control is a few dozen lines and adds
// nothing to a repo that gets pushed.
//
// Escape first, then format. Every line is passed through htmlspecialchars() before any tag is
// introduced, so the only HTML in the output is HTML this function put there. That is what makes
// safe mode unnecessary rather than merely configured — and it is why the $VAR substitution runs
// here, after escaping. Injecting  into the markdown before rendering, as this file used to
// do, meant both Parsedown's safe mode and the 
 fallback escaped the tags and printed them
// as literal text. The feature never worked; nothing called it, so nothing reported it.
function vv_docs_markdown(string $md, array $vars): string {
    if (file_exists(PARSEDOWN_PATH)) {
        require_once PARSEDOWN_PATH;
        $pd = new Parsedown();
        $pd->setSafeMode(true);
        return $pd->text($md);
    }

    $inline = function (string $s) use ($vars): string {
        $s = htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
        // `$VAR` → the live conf value. Unresolved names render in their own class rather than
        // silently reading as prose, so a stale reference in a doc is visible as a defect.
        $s = preg_replace_callback('/`\$([A-Z0-9_]+)`/', function ($m) use ($vars) {
            return isset($vars[$m[1]])
                ? '' . htmlspecialchars($vars[$m[1]]) . ''
                : '$' . $m[1] . '';
        }, $s);
        $s = preg_replace('/`([^`]+)`/', '$1', $s);
        $s = preg_replace('/\*\*([^*]+)\*\*/', '$1', $s);
        $s = preg_replace('/(?$1', $s);
        // Links are restricted to http/https and relative paths — a doc is trusted, but these
        // files sync between hosts, so javascript: must not be reachable through one.
        $s = preg_replace('/\[([^\]]+)\]\((https?:\/\/[^\s)]+|[^\s):]+)\)/', '$1', $s);
        return $s;
    };

    $out = '';
    $list = null;          // 'ul' | 'ol' | null
    $inTable = false;
    $inCode = false;
    $para = [];
    $quote = [];
    $li = [];

    $flushPara = function () use (&$para, &$out, $inline) {
        if (!$para) return;
        $out .= '

' . $inline(implode(' ', $para)) . "

\n"; $para = []; }; $flushQuote = function () use (&$quote, &$out, $inline) { if (!$quote) return; $out .= '
' . $inline(implode(' ', $quote)) . "
\n"; $quote = []; }; // A list item is buffered rather than emitted on sight, because markdown wraps: a bullet // whose text runs onto the next line is one item, not an item followed by a paragraph. $flushLi = function () use (&$li, &$out, $inline) { if (!$li) return; $out .= '
  • ' . $inline(implode(' ', $li)) . "
  • \n"; $li = []; }; $closeList = function () use (&$list, &$out, &$flushLi) { $flushLi(); if ($list) { $out .= "\n"; $list = null; } }; $closeTable = function () use (&$inTable, &$out) { if ($inTable) { $out .= "\n"; $inTable = false; } }; foreach (explode("\n", str_replace("\r\n", "\n", $md)) as $line) { if (preg_match('/^```/', $line)) { $flushPara(); $closeList(); $closeTable(); $out .= $inCode ? "
    \n" : '
    ';
                $inCode = !$inCode;
                continue;
            }
            if ($inCode) { $out .= htmlspecialchars($line, ENT_QUOTES, 'UTF-8') . "\n"; continue; }
    
            $t = trim($line);
    
            // One guard rather than a flush in every branch: the quote ends the moment a line is
            // not a quote line, whatever that next line turns out to be.
            if ($quote && !str_starts_with($t, '>')) $flushQuote();
    
            if ($t === '')                       { $flushPara(); $closeList(); $closeTable(); continue; }
            if (preg_match('/^(---+|\*\*\*+)$/', $t)) { $flushPara(); $closeList(); $closeTable(); $out .= "
    \n"; continue; } if (preg_match('/^(#{1,6})\s+(.*)$/', $t, $m)) { $flushPara(); $closeList(); $closeTable(); $n = strlen($m[1]); $out .= "" . $inline($m[2]) . "\n"; continue; } // Consecutive > lines are one quote, not one per line — same wrapping convention as // paragraphs, which is how they are written. if (preg_match('/^>\s?(.*)$/', $t, $m)) { $closeList(); $closeTable(); if (!$quote) $flushPara(); $quote[] = $m[1]; continue; } // Tables: a header row, a separator of dashes, then body rows. The separator is what // identifies the block — a lone pipe in prose is not a table. if (strpos($t, '|') !== false && preg_match('/^\|?[\s:-]*-[\s|:-]*\|/', $t)) { continue; // separator consumed by the header below } if (strpos($t, '|') !== false && substr_count($t, '|') >= 2) { $cells = array_map('trim', explode('|', trim($t, '| '))); if (!$inTable) { $flushPara(); $closeList(); $out .= ''; foreach ($cells as $c) $out .= ''; $out .= "\n"; $inTable = true; } else { $out .= ''; foreach ($cells as $c) $out .= ''; $out .= "\n"; } continue; } $closeTable(); if (preg_match('/^[-*]\s+(.*)$/', $t, $m)) { $flushPara(); if ($list !== 'ul') { $closeList(); $out .= "
      \n"; $list = 'ul'; } else { $flushLi(); } $li[] = $m[1]; continue; } if (preg_match('/^\d+\.\s+(.*)$/', $t, $m)) { $flushPara(); if ($list !== 'ol') { $closeList(); $out .= "
        \n"; $list = 'ol'; } else { $flushLi(); } $li[] = $m[1]; continue; } // Lazy continuation: plain text directly under an open list item belongs to that item. // Without this a wrapped bullet renders as a bullet plus an orphan paragraph, which is // how the first version of this shipped. if ($list && $li) { $li[] = $t; continue; } $para[] = $t; } $flushQuote(); $flushPara(); $closeList(); $closeTable(); if ($inCode) $out .= "\n"; return $out; }
    ' . $inline($c) . '
    ' . $inline($c) . '