[phd] master: Write redirects.json inside the php-web directory (#270)
| From: | Louis-Arnaud via GitHub | 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"}}