Skip to content

Commit 73661cc

Browse files
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>
1 parent d927213 commit 73661cc

13 files changed

Lines changed: 354 additions & 0 deletions

‎phpdotnet/phd/Redirects.php‎

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
<?php
2+
3+
namespace phpdotnet\phd;
4+
5+
/**
6+
* Renders a language directory's redirects.xml sidecar into
7+
* redirects.json for consumption by web-php/error.php.
8+
*
9+
* If the language root (the parent of --output) has no redirects.xml,
10+
* this is a no-op.
11+
*
12+
* Every `to=` value is validated against xml:ids collected from the
13+
* language root and the docbook root (typically doc-base); a missing
14+
* target aborts the build.
15+
*/
16+
final class Redirects
17+
{
18+
/**
19+
* Prefix fallbacks used when resolving a target xml:id. Mirrors
20+
* the fallback list in web-php/include/manual-lookup.inc so build
21+
* validation matches runtime dispatch.
22+
*/
23+
public const PREFIXES = [
24+
'',
25+
'book.',
26+
'ref.',
27+
'function.',
28+
'class.',
29+
'enum.',
30+
'features.',
31+
'control-structures.',
32+
'language.',
33+
'about.',
34+
'faq.',
35+
];
36+
37+
private const OUTPUT_SUBDIR = 'php-web';
38+
private const OUTPUT_FILE = 'redirects.json';
39+
40+
public static function renderTo(string $outputDir, string $xmlRoot, OutputHandler $out): void
41+
{
42+
$outputDir = rtrim($outputDir, DIRECTORY_SEPARATOR);
43+
$langRoot = dirname($outputDir);
44+
$input = $langRoot . DIRECTORY_SEPARATOR . 'redirects.xml';
45+
if (!file_exists($input)) {
46+
return;
47+
}
48+
49+
$maps = self::parse($input);
50+
$ids = self::collectXmlIds($langRoot) + self::collectXmlIds($xmlRoot);
51+
$broken = self::findBrokenTargets($maps, $ids);
52+
53+
if ($broken !== []) {
54+
$lines = array_map(
55+
static fn(array $b): string => sprintf('[%s] %s -> %s', $b[0], $b[1], $b[2]),
56+
$broken
57+
);
58+
59+
throw new \Error(sprintf(
60+
"Broken redirect targets in %s:\n %s",
61+
$outputDir,
62+
implode("\n ", $lines),
63+
));
64+
}
65+
66+
$output = $outputDir . DIRECTORY_SEPARATOR . self::OUTPUT_SUBDIR;
67+
if (!is_dir($output) && !mkdir($output, 0777, true) && !is_dir($output)) {
68+
throw new \Error('Could not create' . $output);
69+
}
70+
71+
$file = $output . DIRECTORY_SEPARATOR . self::OUTPUT_FILE;
72+
if (file_put_contents($file, json_encode($maps) . "\n") === false) {
73+
throw new \Error('Could not write ' . $file);
74+
}
75+
76+
$out->v(
77+
sprintf(
78+
'Rendered %s (shortcut=%d, in_manual=%d, both=%d)',
79+
$file,
80+
count($maps['shortcut']),
81+
count($maps['in_manual']),
82+
count($maps['both'])
83+
),
84+
VERBOSE_FORMAT_RENDERING,
85+
);
86+
}
87+
88+
/**
89+
* Parse a redirects.xml file into three scope-keyed maps.
90+
*
91+
* @return array{shortcut: array<string,string>, in_manual: array<string,string>, both: array<string,string>}
92+
*/
93+
public static function parse(string $path): array
94+
{
95+
$content = @file_get_contents($path);
96+
if ($content === false) {
97+
throw new \Error("Could not read $path");
98+
}
99+
100+
$prev = libxml_use_internal_errors(true);
101+
libxml_clear_errors();
102+
$xml = simplexml_load_string($content);
103+
libxml_use_internal_errors($prev);
104+
105+
if ($xml === false) {
106+
$err = libxml_get_last_error();
107+
throw new \Error(
108+
sprintf(
109+
'Could not parse %s: %s',
110+
$path,
111+
$err ? trim($err->message) : 'unknown parse error'
112+
)
113+
);
114+
}
115+
116+
$maps = ['shortcut' => [], 'in_manual' => [], 'both' => []];
117+
118+
foreach ($xml->group as $group) {
119+
$scope = (string)$group['scope'];
120+
$key = $scope === 'in-manual' ? 'in_manual' : $scope;
121+
if (!array_key_exists($key, $maps)) {
122+
throw new \Error("Unknown scope in $path: $scope");
123+
}
124+
125+
foreach ($group->redirect as $r) {
126+
$from = (string)$r['from'];
127+
$to = (string)$r['to'];
128+
if ($from === '' || $to === '') {
129+
throw new \Error(sprintf('Empty from/to in %s (scope=%s)', $path, $scope));
130+
}
131+
if (isset($maps[$key][$from])) {
132+
throw new \Error(sprintf('Duplicate key in %s (scope=%s): %s', $path, $scope, $from));
133+
}
134+
$maps[$key][$from] = $to;
135+
}
136+
}
137+
138+
return $maps;
139+
}
140+
141+
/**
142+
* @return array<string,true>
143+
*/
144+
public static function collectXmlIds(string $root): array
145+
{
146+
if (!is_dir($root)) {
147+
return [];
148+
}
149+
150+
$ids = [];
151+
$iter = new \RecursiveIteratorIterator(
152+
new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS)
153+
);
154+
155+
foreach ($iter as $file) {
156+
/** @var \SplFileInfo $file */
157+
if (!$file->isFile() || $file->getExtension() !== 'xml') {
158+
continue;
159+
}
160+
$content = @file_get_contents($file->getPathname());
161+
if ($content === false) {
162+
continue;
163+
}
164+
if (preg_match_all('/xml:id="([^"]+)"/', $content, $m)) {
165+
foreach ($m[1] as $id) {
166+
$ids[$id] = true;
167+
}
168+
}
169+
}
170+
171+
return $ids;
172+
}
173+
174+
/**
175+
* @param array<string,array<string,string>> $maps
176+
* @param array<string,mixed> $ids
177+
* @param array<string> $prefixes
178+
* @return list<array{0:string,1:string,2:string}>
179+
*/
180+
public static function findBrokenTargets(array $maps, array $ids, array $prefixes = self::PREFIXES): array
181+
{
182+
$broken = [];
183+
foreach ($maps as $scope => $entries) {
184+
foreach ($entries as $from => $to) {
185+
$base = explode('#', $to, 2)[0];
186+
foreach ($prefixes as $p) {
187+
$key = $p . $base;
188+
if (isset($ids[$key])) {
189+
continue 2;
190+
}
191+
}
192+
$broken[] = [$scope, $from, $to];
193+
}
194+
}
195+
196+
return $broken;
197+
}
198+
}

‎render.php‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,4 +174,8 @@
174174
}
175175
$render->execute($reader);
176176

177+
// Emit redirects.json alongside the manual output when the doc source
178+
// root ships a redirects.xml sidecar
179+
Redirects::renderTo($config->outputDir, $config->xmlRoot, $outputHandler);
180+
177181
$outputHandler->v("Finished rendering", VERBOSE_FORMAT_RENDERING);
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
--TEST--
2+
Redirects collect_ids 001 - Walk a directory tree and gather xml:ids
3+
--FILE--
4+
<?php
5+
namespace phpdotnet\phd;
6+
7+
require_once __DIR__ . '/../setup.php';
8+
9+
$ids = Redirects::collectXmlIds(__DIR__ . '/data/docs');
10+
$keys = array_keys($ids);
11+
sort($keys);
12+
foreach ($keys as $id) {
13+
echo $id . PHP_EOL;
14+
}
15+
?>
16+
--EXPECT--
17+
book.bc
18+
class.newname
19+
control-structures.if
20+
install
21+
page.foo
22+
some.anchor
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<chapter xmlns="http://docbook.org/ns/docbook" xml:id="control-structures.if">
3+
<title>if</title>
4+
</chapter>

‎tests/redirects/data/docs/book.xml‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<book xmlns="http://docbook.org/ns/docbook" xml:id="book.bc">
3+
<title>BC</title>
4+
<chapter xml:id="page.foo">
5+
<title>Page</title>
6+
<sect1 xml:id="some.anchor"><title>Anchor</title></sect1>
7+
</chapter>
8+
<chapter xml:id="install"><title>Install</title></chapter>
9+
</book>
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<refentry xmlns="http://docbook.org/ns/docbook" xml:id="class.newname">
3+
<title>ClassNewName</title>
4+
</refentry>
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<redirects>
3+
<group scope="shortcut">
4+
<redirect from="if" to="control-structures.if"/>
5+
<redirect from="if" to="language.control-structures"/>
6+
</group>
7+
</redirects>
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<redirects>
3+
<group scope="in-manual">
4+
<redirect from="class.oldname" to="class.newname"/>
5+
</group>
6+
<group scope="shortcut">
7+
<redirect from="if" to="control-structures.if"/>
8+
<redirect from="anchored" to="page.foo#some.anchor"/>
9+
<redirect from="bcmath" to="bc"/>
10+
</group>
11+
<group scope="both">
12+
<redirect from="installation" to="install"/>
13+
</group>
14+
</redirects>
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<redirects>
3+
<group scope="shortcut">
4+
<redirect from="if" to="control-structures.if"/>
5+
<redirect from="legacy" to="gone.page"/>
6+
</group>
7+
<group scope="in-manual">
8+
<redirect from="oldid" to="still.missing"/>
9+
</group>
10+
</redirects>

‎tests/redirects/parse_001.phpt‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
--TEST--
2+
Redirects parse 001 - Duplicate `from` inside a scope throws
3+
--FILE--
4+
<?php
5+
namespace phpdotnet\phd;
6+
7+
require_once __DIR__ . '/../setup.php';
8+
9+
try {
10+
Redirects::parse(__DIR__ . '/data/parse_001_duplicate.xml');
11+
echo 'no error' . PHP_EOL;
12+
} catch (\Error $e) {
13+
echo $e->getMessage() . PHP_EOL;
14+
}
15+
?>
16+
--EXPECTF--
17+
Duplicate key in %sparse_001_duplicate.xml (scope=shortcut): if

0 commit comments

Comments
 (0)