[phd] master: Write redirects.json inside the php-web directory (#270)

From: Date: Tue, 27 Oct 2026 12:13:50 +0000
Subject: [phd] master: Write redirects.json inside the php-web directory (#270)
Groups: php.doc.cvs 
Request: Send a blank email to doc-cvs+get-23895@lists.php.net to get a copy of this message
Author: Louis-Arnaud (lacatoire) Committer: GitHub (web-flow) Pusher: jordikroon Date: 2026-10-27T13:13:46+01:00 Commit: https://github.com/php/phd/commit/73661ccd5f85fb4480716800ae2ea36569d2fc6f Raw diff: https://github.com/php/phd/commit/73661ccd5f85fb4480716800ae2ea36569d2fc6f.diff Write redirects.json inside the php-web directory (#270) * add class to generate redirects.json from redirects.xml sidecar * Write redirects.json inside the php-web directory Redirects::renderTo() created the php-web directory and then tried to write the JSON onto that same path, which fails with "Is a directory". Write the file inside the directory and cover renderTo() with a test. --------- Co-authored-by: Jordi Kroon <jordikroon@php.net> Changed paths: A phpdotnet/phd/Redirects.php A tests/redirects/collect_ids_001.phpt A tests/redirects/data/docs/basic.xml A tests/redirects/data/docs/book.xml A tests/redirects/data/docs/nested/deep.xml A tests/redirects/data/parse_001_duplicate.xml A tests/redirects/data/render_001.xml A tests/redirects/data/render_002_broken.xml A tests/redirects/parse_001.phpt A tests/redirects/render_001.phpt A tests/redirects/render_002.phpt A tests/redirects/render_003.phpt M render.php Diff: diff --git a/phpdotnet/phd/Redirects.php b/phpdotnet/phd/Redirects.php new file mode 100644 index 000000000..0d8082d9e --- /dev/null +++ b/phpdotnet/phd/Redirects.php @@ -0,0 +1,198 @@ +<?php + +namespace phpdotnet\phd; + +/** + * Renders a language directory's redirects.xml sidecar into + * redirects.json for consumption by web-php/error.php. + * + * If the language root (the parent of --output) has no redirects.xml, + * this is a no-op. + * + * Every to= value is validated against xml:ids collected from the + * language root and the docbook root (typically doc-base); a missing + * target aborts the build. + */ +final class Redirects +{ + /** + * Prefix fallbacks used when resolving a target xml:id. Mirrors + * the fallback list in web-php/include/manual-lookup.inc so build + * validation matches runtime dispatch. + */ + public const PREFIXES = [ + '', + 'book.', + 'ref.', + 'function.', + 'class.', + 'enum.', + 'features.', + 'control-structures.', + 'language.', + 'about.', + 'faq.', + ]; + + private const OUTPUT_SUBDIR = 'php-web'; + private const OUTPUT_FILE = 'redirects.json'; + + public static function renderTo(string $outputDir, string $xmlRoot, OutputHandler $out): void + { + $outputDir = rtrim($outputDir, DIRECTORY_SEPARATOR); + $langRoot = dirname($outputDir); + $input = $langRoot . DIRECTORY_SEPARATOR . 'redirects.xml'; + if (!file_exists($input)) { + return; + } + + $maps = self::parse($input); + $ids = self::collectXmlIds($langRoot) + self::collectXmlIds($xmlRoot); + $broken = self::findBrokenTargets($maps, $ids); + + if ($broken !== []) { + $lines = array_map( + static fn(array $b): string => sprintf('[%s] %s -> %s', $b[0], $b[1], $b[2]), + $broken + ); + + throw new \Error(sprintf( + "Broken redirect targets in %s:\n %s", + $outputDir, + implode("\n ", $lines), + )); + } + + $output = $outputDir . DIRECTORY_SEPARATOR . self::OUTPUT_SUBDIR; + if (!is_dir($output) && !mkdir($output, 0777, true) && !is_dir($output)) { + throw new \Error('Could not create' . $output); + } + + $file = $output . DIRECTORY_SEPARATOR . self::OUTPUT_FILE; + if (file_put_contents($file, json_encode($maps) . "\n") === false) { + throw new \Error('Could not write ' . $file); + } + + $out->v( + sprintf( + 'Rendered %s (shortcut=%d, in_manual=%d, both=%d)', + $file, + count($maps['shortcut']), + count($maps['in_manual']), + count($maps['both']) + ), + VERBOSE_FORMAT_RENDERING, + ); + } + + /** + * Parse a redirects.xml file into three scope-keyed maps. + * + * @return array{shortcut: array<string,string>, in_manual: array<string,string>, both: array<string,string>} + */ + public static function parse(string $path): array + { + $content = @file_get_contents($path); + if ($content === false) { + throw new \Error("Could not read $path"); + } + + $prev = libxml_use_internal_errors(true); + libxml_clear_errors(); + $xml = simplexml_load_string($content); + libxml_use_internal_errors($prev); + + if ($xml === false) { + $err = libxml_get_last_error(); + throw new \Error( + sprintf( + 'Could not parse %s: %s', + $path, + $err ? trim($err->message) : 'unknown parse error' + ) + ); + } + + $maps = ['shortcut' => [], 'in_manual' => [], 'both' => []]; + + foreach ($xml->group as $group) { + $scope = (string)$group['scope']; + $key = $scope === 'in-manual' ? 'in_manual' : $scope; + if (!array_key_exists($key, $maps)) { + throw new \Error("Unknown scope in $path: $scope"); + } + + foreach ($group->redirect as $r) { + $from = (string)$r['from']; + $to = (string)$r['to']; + if ($from === '' || $to === '') { + throw new \Error(sprintf('Empty from/to in %s (scope=%s)', $path, $scope)); + } + if (isset($maps[$key][$from])) { + throw new \Error(sprintf('Duplicate key in %s (scope=%s): %s', $path, $scope, $from)); + } + $maps[$key][$from] = $to; + } + } + + return $maps; + } + + /** + * @return array<string,true> + */ + public static function collectXmlIds(string $root): array + { + if (!is_dir($root)) { + return []; + } + + $ids = []; + $iter = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS) + ); + + foreach ($iter as $file) { + /** @var \SplFileInfo $file */ + if (!$file->isFile() || $file->getExtension() !== 'xml') { + continue; + } + $content = @file_get_contents($file->getPathname()); + if ($content === false) { + continue; + } + if (preg_match_all('/xml:id="([^"]+)"/', $content, $m)) { + foreach ($m[1] as $id) { + $ids[$id] = true; + } + } + } + + return $ids; + } + + /** + * @param array<string,array<string,string>> $maps + * @param array<string,mixed> $ids + * @param array<string> $prefixes + * @return list<array{0:string,1:string,2:string}> + */ + public static function findBrokenTargets(array $maps, array $ids, array $prefixes = self::PREFIXES): array + { + $broken = []; + foreach ($maps as $scope => $entries) { + foreach ($entries as $from => $to) { + $base = explode('#', $to, 2)[0]; + foreach ($prefixes as $p) { + $key = $p . $base; + if (isset($ids[$key])) { + continue 2; + } + } + $broken[] = [$scope, $from, $to]; + } + } + + return $broken; + } +} diff --git a/render.php b/render.php index 15f8c7b87..00b57d404 100644 --- a/render.php +++ b/render.php @@ -174,4 +174,8 @@ } $render->execute($reader); +// Emit redirects.json alongside the manual output when the doc source +// root ships a redirects.xml sidecar +Redirects::renderTo($config->outputDir, $config->xmlRoot, $outputHandler); + $outputHandler->v("Finished rendering", VERBOSE_FORMAT_RENDERING); diff --git a/tests/redirects/collect_ids_001.phpt b/tests/redirects/collect_ids_001.phpt new file mode 100644 index 000000000..311885692 --- /dev/null +++ b/tests/redirects/collect_ids_001.phpt @@ -0,0 +1,22 @@ +--TEST-- +Redirects collect_ids 001 - Walk a directory tree and gather xml:ids +--FILE-- +<?php +namespace phpdotnet\phd; + +require_once __DIR__ . '/../setup.php'; + +$ids = Redirects::collectXmlIds(__DIR__ . '/data/docs'); +$keys = array_keys($ids); +sort($keys); +foreach ($keys as $id) { + echo $id . PHP_EOL; +} +?> +--EXPECT-- +book.bc +class.newname +control-structures.if +install +page.foo +some.anchor diff --git a/tests/redirects/data/docs/basic.xml b/tests/redirects/data/docs/basic.xml new file mode 100644 index 000000000..9e2b39219 --- /dev/null +++ b/tests/redirects/data/docs/basic.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<chapter xmlns="http://docbook.org/ns/docbook" xml:id="control-structures.if"> + <title>if</title> +</chapter> diff --git a/tests/redirects/data/docs/book.xml b/tests/redirects/data/docs/book.xml new file mode 100644 index 000000000..2f19c0556 --- /dev/null +++ b/tests/redirects/data/docs/book.xml @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="utf-8"?> +<book xmlns="http://docbook.org/ns/docbook" xml:id="book.bc"> + <title>BC</title> + <chapter xml:id="page.foo"> + <title>Page</title> + <sect1 xml:id="some.anchor"><title>Anchor</title></sect1> + </chapter> + <chapter xml:id="install"><title>Install</title></chapter> +</book> diff --git a/tests/redirects/data/docs/nested/deep.xml b/tests/redirects/data/docs/nested/deep.xml new file mode 100644 index 000000000..e1b37b0ac --- /dev/null +++ b/tests/redirects/data/docs/nested/deep.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<refentry xmlns="http://docbook.org/ns/docbook" xml:id="class.newname"> + <title>ClassNewName</title> +</refentry> diff --git a/tests/redirects/data/parse_001_duplicate.xml b/tests/redirects/data/parse_001_duplicate.xml new file mode 100644 index 000000000..65b3c7456 --- /dev/null +++ b/tests/redirects/data/parse_001_duplicate.xml @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="utf-8"?> +<redirects> + <group scope="shortcut"> + <redirect from="if" to="control-structures.if"/> + <redirect from="if" to="language.control-structures"/> + </group> +</redirects> diff --git a/tests/redirects/data/render_001.xml b/tests/redirects/data/render_001.xml new file mode 100644 index 000000000..ab736f935 --- /dev/null +++ b/tests/redirects/data/render_001.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="utf-8"?> +<redirects> + <group scope="in-manual"> + <redirect from="class.oldname" to="class.newname"/> + </group> + <group scope="shortcut"> + <redirect from="if" to="control-structures.if"/> + <redirect from="anchored" to="page.foo#some.anchor"/> + <redirect from="bcmath" to="bc"/> + </group> + <group scope="both"> + <redirect from="installation" to="install"/> + </group> +</redirects> diff --git a/tests/redirects/data/render_002_broken.xml b/tests/redirects/data/render_002_broken.xml new file mode 100644 index 000000000..4a0dc17c4 --- /dev/null +++ b/tests/redirects/data/render_002_broken.xml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="utf-8"?> +<redirects> + <group scope="shortcut"> + <redirect from="if" to="control-structures.if"/> + <redirect from="legacy" to="gone.page"/> + </group> + <group scope="in-manual"> + <redirect from="oldid" to="still.missing"/> + </group> +</redirects> diff --git a/tests/redirects/parse_001.phpt b/tests/redirects/parse_001.phpt new file mode 100644 index 000000000..5bd1ea8c3 --- /dev/null +++ b/tests/redirects/parse_001.phpt @@ -0,0 +1,17 @@ +--TEST-- +Redirects parse 001 - Duplicate from inside a scope throws +--FILE-- +<?php +namespace phpdotnet\phd; + +require_once __DIR__ . '/../setup.php'; + +try { + Redirects::parse(__DIR__ . '/data/parse_001_duplicate.xml'); + echo 'no error' . PHP_EOL; +} catch (\Error $e) { + echo $e->getMessage() . PHP_EOL; +} +?> +--EXPECTF-- +Duplicate key in %sparse_001_duplicate.xml (scope=shortcut): if diff --git a/tests/redirects/render_001.phpt b/tests/redirects/render_001.phpt new file mode 100644 index 000000000..2c2859a90 --- /dev/null +++ b/tests/redirects/render_001.phpt @@ -0,0 +1,18 @@ +--TEST-- +Redirects 001 - Parse a well-formed redirects.xml and emit the JSON +--FILE-- +<?php +namespace phpdotnet\phd; + +require_once __DIR__ . '/../setup.php'; + +$maps = Redirects::parse(__DIR__ . '/data/render_001.xml'); +$ids = Redirects::collectXmlIds(__DIR__ . '/data/docs'); +$broken = Redirects::findBrokenTargets($maps, $ids); + +echo 'Broken targets: ' . count($broken) . PHP_EOL; +echo json_encode($maps) . PHP_EOL; +?> +--EXPECT-- +Broken targets: 0 +{"shortcut":{"if":"control-structures.if","anchored":"page.foo#some.anchor","bcmath":"bc"},"in_manual":{"class.oldname":"class.newname"},"both":{"installation":"install"}} diff --git a/tests/redirects/render_002.phpt b/tests/redirects/render_002.phpt new file mode 100644 index 000000000..61ead9f02 --- /dev/null +++ b/tests/redirects/render_002.phpt @@ -0,0 +1,18 @@ +--TEST-- +Redirects 002 - Broken targets are reported by scope + from -> to +--FILE-- +<?php +namespace phpdotnet\phd; + +require_once __DIR__ . '/../setup.php'; + +$maps = Redirects::parse(__DIR__ . '/data/render_002_broken.xml'); +$ids = Redirects::collectXmlIds(__DIR__ . '/data/docs'); + +foreach (Redirects::findBrokenTargets($maps, $ids) as [$scope, $from, $to]) { + echo $scope . ': ' . $from . ' -> ' . $to . PHP_EOL; +} +?> +--EXPECT-- +shortcut: legacy -> gone.page +in_manual: oldid -> still.missing diff --git a/tests/redirects/render_003.phpt b/tests/redirects/render_003.phpt new file mode 100644 index 000000000..8ad4c2408 --- /dev/null +++ b/tests/redirects/render_003.phpt @@ -0,0 +1,29 @@ +--TEST-- +Redirects 003 - renderTo writes redirects.json into the php-web directory +--FILE-- +<?php +namespace phpdotnet\phd; + +require_once __DIR__ . '/../setup.php'; + +$lang = __DIR__ . '/data/render_003_lang'; +$output = $lang . '/output'; +mkdir($output, 0777, true); +copy(__DIR__ . '/data/render_001.xml', $lang . '/redirects.xml'); + +// renderTo() reports the written path (absolute) through the verbose channel. +$reporting = error_reporting(error_reporting() & ~VERBOSE_FORMAT_RENDERING); +Redirects::renderTo($output, __DIR__ . '/data/docs', $outputHandler); +error_reporting($reporting); + +$file = $output . '/php-web/redirects.json'; +echo is_file($file) ? file_get_contents($file) : 'missing' . PHP_EOL; + +unlink($file); +rmdir($output . '/php-web'); +rmdir($output); +unlink($lang . '/redirects.xml'); +rmdir($lang); +?> +--EXPECT-- +{"shortcut":{"if":"control-structures.if","anchored":"page.foo#some.anchor","bcmath":"bc"},"in_manual":{"class.oldname":"class.newname"},"both":{"installation":"install"}}

« previous php.doc.cvs (#23895) next »