From 2ecff45179db9cd20db9ab6138a053616259f3d8 Mon Sep 17 00:00:00 2001 From: lacatoire Date: Tue, 7 Jul 2026 22:41:03 +0200 Subject: [PATCH 1/3] Add genphdfiles.php: generate PhD auxiliary files from phd-conf.json Generates version.xml, sources.xml and fileModHistory.php (previously produced by doc-base/configure.php) from the doc-base/temp/phd-conf.json handoff, so that configure.php can focus on assembling and validating the XML. Companion to a php/doc-base change that removes this generation from configure.php. Wire into the build pipeline with: php phd/genphdfiles.php doc-base/temp/phd-conf.json --- genphdfiles.php | 231 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 genphdfiles.php diff --git a/genphdfiles.php b/genphdfiles.php new file mode 100644 index 00000000..10a61324 --- /dev/null +++ b/genphdfiles.php @@ -0,0 +1,231 @@ + version banners) + * - sources.xml (xml:id -> {lang, path} map) + * - fileModHistory.php (file modification history) + * + * These were historically produced as a tail step of doc-base/configure.php. + * They are now generated here, from the doc-base/temp/phd-conf.json handoff, + * so that configure.php can focus solely on assembling and validating the XML. + * + * Usage: + * php genphdfiles.php [path/to/phd-conf.json] + * + * The logic mirrors the former configure.php functions verbatim, to keep the + * output byte-identical. + */ + +$confPath = $argv[1] ?? (__DIR__ . '/../doc-base/temp/phd-conf.json'); +if (!is_file($confPath)) { + fwrite(STDERR, "phd-conf.json not found: {$confPath}\n"); + exit(1); +} +$conf = json_decode(file_get_contents($confPath), true); +if (!is_array($conf)) { + fwrite(STDERR, "phd-conf.json is not valid JSON: {$confPath}\n"); + exit(1); +} + +if (!empty($conf['outputs']['history'])) { + phd_history($conf); +} +if (!empty($conf['outputs']['sources'])) { + phd_sources($conf); +} +if (!empty($conf['outputs']['version'])) { + phd_version($conf); +} + +exit(0); + +// ----------------------------------------------------------------------------- + +function phd_history(array $conf): void +{ + echo 'PhD history:'; + + $lang_mod_file = (($conf['lang'] !== 'en') + ? "{$conf['rootdir']}/{$conf['enDir']}" + : "{$conf['rootdir']}/{$conf['langDir']}") . "/fileModHistory.php"; + $doc_base_mod_file = $conf['srcdir'] . "/fileModHistory.php"; + + $history_file = null; + if (file_exists($lang_mod_file)) { + $history_file = include $lang_mod_file; + if (is_array($history_file)) { + echo ' copy,'; + $isFileCopied = copy($lang_mod_file, $doc_base_mod_file); + echo $isFileCopied ? "" : " failed,"; + } else { + echo " corrupted file '$lang_mod_file,'"; + } + } else { + echo " not found,"; + } + + if (!is_array($history_file)) { + $history_file = []; + echo " creating empty,"; + file_put_contents($doc_base_mod_file, " $source_lang, + 'path' => $source_path, + ); + } + } + } + } + asort($source_map); + echo ' transforming,'; + $dom = new DOMDocument; + $dom->formatOutput = true; + $sources_elem = $dom->appendChild($dom->createElement("sources")); + foreach ($source_map as $id => $source) { + $el = $dom->createElement('item'); + $el->setAttribute('id', $id); + $el->setAttribute('lang', $source["lang"]); + $el->setAttribute('path', $source["path"]); + $sources_elem->appendChild($el); + } + echo " saving,"; + if ($dom->save($conf['srcdir'] . '/sources.xml')) { + echo " done.\n"; + } else { + echo " fail!\n"; + } +} + +function phd_version(array $conf): void +{ + echo 'PhD version:'; + + $dom = new DOMDocument; + $dom->preserveWhiteSpace = false; + $dom->formatOutput = true; + + $tmp = new DOMDocument; + $tmp->preserveWhiteSpace = false; + + $versions = $dom->appendChild($dom->createElement("versions")); + + echo ' reading,'; + if ($conf["generate"] != "no") { + $globdir = dirname($conf["generate"]) . "/{../../}versions.xml"; + } + else { + $globdir = $conf['rootdir'] . '/en'; + $globdir .= "/*/*/versions.xml"; + } + echo ' transforming,'; + if (!defined('GLOB_BRACE')) { + define('GLOB_BRACE', 0); + } + foreach(glob($globdir, GLOB_BRACE) as $file) { + if($tmp->load($file)) { + foreach($tmp->getElementsByTagName("function") as $function) { + $function = $dom->importNode($function, true); + $versions->appendChild($function); + } + } else { + print_xml_errors($conf); + errors_are_bad(1); + } + } + echo ' saving,'; + + if ($dom->save($conf['srcdir'] . '/version.xml')) { + echo " done.\n"; + } else { + echo " fail!\n"; + } +} + +// ----------------------------------------------------------------------------- +// Helpers ported from doc-base/configure.php +// ----------------------------------------------------------------------------- + +function find_xml_files($path) +{ + $path = rtrim($path, '/'); + $prefix_len = strlen($path . '/'); + $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($path)); + foreach ($files as $fileinfo) { + if ($fileinfo->getExtension() === 'xml') { + yield substr($fileinfo->getPathname(), $prefix_len); + } + } +} + +function print_xml_errors(array $conf): void +{ + $report = $conf['lang'] == 'en' || !empty($conf['xpointerReporting']); + $output = !empty($conf['stderrToStdout']) ? STDOUT : STDERR; + + $errors = libxml_get_errors(); + libxml_clear_errors(); + + $filePrefix = "file:///"; + $tempPrefix = realpath($conf['srcdir'] . "/temp") . "/"; + $rootPrefix = realpath($conf['rootdir']) . "/"; + + if (count($errors) > 0) { + fprintf($output, "\n"); + } + + foreach ($errors as $error) { + $mssg = rtrim($error->message); + $file = $error->file; + $line = $error->line; + $clmn = $error->column; + + if (str_starts_with($mssg, 'XPointer evaluation failed:') && !$report) { + continue; + } + + if (str_starts_with($file, $filePrefix)) { + $file = substr($file, strlen($filePrefix)); + } + if (str_starts_with($file, $tempPrefix)) { + $file = substr($file, strlen($tempPrefix)); + } + if (str_starts_with($file, $rootPrefix)) { + $file = substr($file, strlen($rootPrefix)); + } + + $prefix = $error->level === LIBXML_ERR_FATAL ? "FATAL" : "error"; + + fwrite($output, "[$prefix $file {$line}:{$clmn}] {$mssg}\n"); + } +} + +function errors_are_bad($status) +{ + echo "\nEyh man. No worries. Happ shittens. Try again after fixing the errors above.\n"; + exit($status); +} From 77b1ff7bbf5e50f2c6f0e77f2b307f4cc7ffcf18 Mon Sep 17 00:00:00 2001 From: lacatoire Date: Wed, 8 Jul 2026 12:11:32 +0200 Subject: [PATCH 2/3] Add tests for genphdfiles.php (en + translation branch) --- tests/genphdfiles/genphdfiles_001.phpt | 56 ++++++++++++++++++++++++++ tests/genphdfiles/genphdfiles_002.phpt | 55 +++++++++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 tests/genphdfiles/genphdfiles_001.phpt create mode 100644 tests/genphdfiles/genphdfiles_002.phpt diff --git a/tests/genphdfiles/genphdfiles_001.phpt b/tests/genphdfiles/genphdfiles_001.phpt new file mode 100644 index 00000000..f883fd21 --- /dev/null +++ b/tests/genphdfiles/genphdfiles_001.phpt @@ -0,0 +1,56 @@ +--TEST-- +genphdfiles 001 - generate version.xml, sources.xml and fileModHistory.php (en) +--FILE-- +\n\n"); +file_put_contents("$root/funcindex.xml", + "\n\n"); +file_put_contents("$root/en/reference/foo/versions.xml", + "\n\n \n\n"); +file_put_contents("$root/en/reference/foo/foo.xml", + "\n\n"); + +$conf = [ + 'rootdir' => $root, 'srcdir' => $root, 'lang' => 'en', + 'enDir' => 'en', 'langDir' => 'en', 'generate' => 'no', + 'xpointerReporting' => true, 'stderrToStdout' => false, + 'outputs' => ['version' => true, 'sources' => true, 'history' => true], +]; +file_put_contents("$root/phd-conf.json", json_encode($conf)); + +exec(escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg(__DIR__ . '/../../genphdfiles.php') + . ' ' . escapeshellarg("$root/phd-conf.json"), $out, $rc); + +echo "rc=$rc\n"; +echo "== version.xml ==\n" . file_get_contents("$root/version.xml"); +echo "== sources.xml ==\n" . file_get_contents("$root/sources.xml"); +echo "== fileModHistory.php ==\n" . file_get_contents("$root/fileModHistory.php"); + +$it = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST); +foreach ($it as $f) { $f->isDir() ? rmdir($f) : unlink($f); } +rmdir($root); +?> +--EXPECT-- +rc=0 +== version.xml == + + + + +== sources.xml == + + + + + + +== fileModHistory.php == +\n\n"); +file_put_contents("$root/funcindex.xml", + "\n\n"); +file_put_contents("$root/en/reference/foo/versions.xml", + "\n\n \n\n"); +file_put_contents("$root/en/reference/foo/foo.xml", + "\n\n"); +// The fr translation carries the same xml:id, so it must win in the source map. +file_put_contents("$root/fr/reference/foo/foo.xml", + "\n\n"); + +$conf = [ + 'rootdir' => $root, 'srcdir' => $root, 'lang' => 'fr', + 'enDir' => 'en', 'langDir' => 'fr', 'generate' => 'no', + 'xpointerReporting' => true, 'stderrToStdout' => false, + 'outputs' => ['version' => true, 'sources' => true, 'history' => true], +]; +file_put_contents("$root/phd-conf.json", json_encode($conf)); + +exec(escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg(__DIR__ . '/../../genphdfiles.php') + . ' ' . escapeshellarg("$root/phd-conf.json"), $out, $rc); + +echo "rc=$rc\n"; +echo "== version.xml ==\n" . file_get_contents("$root/version.xml"); +echo "== sources.xml ==\n" . file_get_contents("$root/sources.xml"); + +$it = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST); +foreach ($it as $f) { $f->isDir() ? rmdir($f) : unlink($f); } +rmdir($root); +?> +--EXPECT-- +rc=0 +== version.xml == + + + + +== sources.xml == + + + + + + From 07ba1e0a21accb9b2d607f9dde0757ef1b8806a0 Mon Sep 17 00:00:00 2001 From: lacatoire Date: Wed, 8 Jul 2026 16:24:04 +0200 Subject: [PATCH 3/3] Cache sources.xml under temp/ to skip the scan on repeated runs phd_sources() reads every XML file of the manual, so it dominates the cost of these generators. When srcdir/temp/ exists (configure.php always creates and wipes it per run), stash a copy of sources.xml there and short-circuit to it on the next run. This keeps repeated PhD runs fast during development without ever going out of sync with manual.xml, which is regenerated in the same temp/ directory. The historical sources.xml is still written by the same DOM save, so the output stays byte-identical. --- genphdfiles.php | 19 +++++++- tests/genphdfiles/genphdfiles_003.phpt | 64 ++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 tests/genphdfiles/genphdfiles_003.phpt diff --git a/genphdfiles.php b/genphdfiles.php index 10a61324..3ba8c770 100644 --- a/genphdfiles.php +++ b/genphdfiles.php @@ -77,6 +77,20 @@ function phd_sources(array $conf): void { echo 'PhD sources:'; + $dest = $conf['srcdir'] . '/sources.xml'; + $cache = $conf['srcdir'] . '/temp/phd-sources.xml'; + // The source scan below reads every XML file of the manual, so it is by far + // the most expensive of these generators. configure.php wipes temp/ on + // every run, so a copy stashed there can be reused by repeated PhD runs + // without ever going out of sync with the manual.xml generated alongside it. + $cacheable = is_dir(dirname($cache)); + + if ($cacheable && is_file($cache)) { + echo ' cached,'; + echo copy($cache, $dest) ? " done.\n" : " fail!\n"; + return; + } + echo ' reading,'; $source_map = array(); $en_dir = "{$conf['rootdir']}/{$conf['enDir']}"; @@ -114,7 +128,10 @@ function phd_sources(array $conf): void $sources_elem->appendChild($el); } echo " saving,"; - if ($dom->save($conf['srcdir'] . '/sources.xml')) { + if ($dom->save($dest)) { + if ($cacheable) { + copy($dest, $cache); + } echo " done.\n"; } else { echo " fail!\n"; diff --git a/tests/genphdfiles/genphdfiles_003.phpt b/tests/genphdfiles/genphdfiles_003.phpt new file mode 100644 index 00000000..0bf2fd73 --- /dev/null +++ b/tests/genphdfiles/genphdfiles_003.phpt @@ -0,0 +1,64 @@ +--TEST-- +genphdfiles 003 - sources.xml is cached under temp/ and reused on a second run +--FILE-- +\n\n"); +file_put_contents("$root/funcindex.xml", + "\n\n"); +file_put_contents("$root/en/reference/foo/versions.xml", + "\n\n \n\n"); +file_put_contents("$root/en/reference/foo/foo.xml", + "\n\n"); + +$conf = [ + 'rootdir' => $root, 'srcdir' => $root, 'lang' => 'en', + 'enDir' => 'en', 'langDir' => 'en', 'generate' => 'no', + 'xpointerReporting' => true, 'stderrToStdout' => false, + 'outputs' => ['version' => false, 'sources' => true, 'history' => false], +]; +file_put_contents("$root/phd-conf.json", json_encode($conf)); + +$cmd = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg(__DIR__ . '/../../genphdfiles.php') + . ' ' . escapeshellarg("$root/phd-conf.json"); + +// First run: cold, scans the sources and stashes the copy in temp/. +exec($cmd, $out1, $rc1); +echo "run1: $rc1 " . implode('', $out1) . "\n"; +echo "cache exists: " . (is_file("$root/temp/phd-sources.xml") ? "yes" : "no") . "\n"; +$first = file_get_contents("$root/sources.xml"); + +// Delete the sources so a scan-based run could not recreate them identically by +// accident, then remove the input files so only the cache can serve run 2. +unlink("$root/sources.xml"); +unlink("$root/en/reference/foo/foo.xml"); + +exec($cmd, $out2, $rc2); +echo "run2: $rc2 " . implode('', $out2) . "\n"; +$second = file_get_contents("$root/sources.xml"); + +echo "identical: " . ($first === $second ? "yes" : "no") . "\n"; +echo "== sources.xml ==\n" . $second; + +$it = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST); +foreach ($it as $f) { $f->isDir() ? rmdir($f) : unlink($f); } +rmdir($root); +?> +--EXPECT-- +run1: 0 PhD sources: reading, transforming, saving, done. +cache exists: yes +run2: 0 PhD sources: cached, done. +identical: yes +== sources.xml == + + + + + +