diff --git a/.docker/Dockerfile b/.docker/Dockerfile
new file mode 100644
index 0000000000..5d7552229f
--- /dev/null
+++ b/.docker/Dockerfile
@@ -0,0 +1,21 @@
+FROM php:8.4-cli
+
+ARG UID=1000
+ARG GID=1000
+
+RUN apt-get update && \
+ apt-get install -y git default-jre-headless
+
+WORKDIR /var/www
+
+ADD https://api.github.com/repos/php/phd/git/refs/heads/master version-phd.json
+ADD https://api.github.com/repos/php/docbook-cs/git/refs/heads/main version-docbook-cs.json
+
+RUN echo 'memory_limit = 512M' >> /usr/local/etc/php/conf.d/local.ini
+
+RUN chown $UID:$GID /var/www
+
+USER $UID:$GID
+
+RUN git clone --depth 1 https://github.com/php/phd.git && \
+ git clone --depth 1 https://github.com/php/docbook-cs.git
diff --git a/.gitignore b/.gitignore
index 8e5220c8b2..4c694b86af 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,3 +9,6 @@ fileModHistory.php
# A plece for all temporary or generated files (idempotent build)
temp/
+
+# Docker dev build stamp
+.docker/built
diff --git a/dev.php b/dev.php
new file mode 100755
index 0000000000..eafbf2bf19
--- /dev/null
+++ b/dev.php
@@ -0,0 +1,17 @@
+#!/usr/bin/env php
+run(array_slice($argv, 1)));
diff --git a/docbookcs.dev.xml b/docbookcs.dev.xml
new file mode 100644
index 0000000000..b6405720b3
--- /dev/null
+++ b/docbookcs.dev.xml
@@ -0,0 +1,44 @@
+
+
+
+
+
+ @LANG@
+ doc-base
+
+
+
+
+
+
+
+
+
+
+
+ .
+
+
+
+ extensions.ent
+ language-defs.ent
+ language-snippets.ent
+ entities/
+ ../doc-base/entities/
+ ../doc-base/temp/manual.ent
+ ../doc-base/temp/entities.ent
+ ../doc-base/temp/file-entities.ent
+ ../doc-base/temp/file-entities
+
+
+
+ output/*
+
+
+
diff --git a/scripts/dev/Application.php b/scripts/dev/Application.php
new file mode 100644
index 0000000000..e978f3eb61
--- /dev/null
+++ b/scripts/dev/Application.php
@@ -0,0 +1,182 @@
+ $args Command line arguments, without argv[0].
+ */
+ public function run(array $args): int
+ {
+ if (!$this->requirePhpVersion(self::MINIMUM_PHP_VERSION)) {
+ return 1;
+ }
+
+ $options = new Options();
+ $command = $this->parse($args, $options);
+
+ if ($command === null) {
+ return 1;
+ }
+
+ if ($command === 'help') {
+ return (new HelpCommand())->execute($options);
+ }
+
+ $subcommand = null;
+
+ if ($command === 'docker') {
+ $subcommand = array_shift($options->args);
+
+ if (!in_array($subcommand, ['build', 'shell'], true)) {
+ fwrite(STDERR, "Usage: php dev.php docker (see: php dev.php help)\n");
+ return 1;
+ }
+
+ $options->docker = true;
+ }
+
+ if ($command === 'render') {
+ $subcommand = array_shift($options->args);
+
+ if (!in_array($subcommand, ['xhtml', 'php'], true)) {
+ fwrite(STDERR, "Usage: php dev.php render (see: php dev.php help)\n");
+ return 1;
+ }
+
+ $options->format = $subcommand;
+ }
+
+ if ($command === 'cs') {
+ $subcommand = array_shift($options->args);
+
+ if (!in_array($subcommand, ['lint', 'fix'], true)) {
+ fwrite(STDERR, "Usage: php dev.php cs (see: php dev.php help)\n");
+ return 1;
+ }
+ }
+
+ $runner = new ProcessRunner();
+ $dockerAvailable = $runner->runQuiet(['docker', 'version', '--format', '{{.Server.Version}}']) === 0;
+
+ if ($options->docker === true && !$dockerAvailable) {
+ fwrite(STDERR, "error: Docker requested but the docker command is not available.\n");
+ return 1;
+ }
+
+ $workspace = new Workspace($this->basedir, $runner, $options->assumeYes);
+ $environment = ($options->docker ?? $dockerAvailable)
+ ? new DockerEnvironment($workspace, $runner)
+ : new LocalEnvironment($workspace, $runner);
+
+ $configure = new ConfigureCommand($workspace, $environment);
+
+ switch ($command) {
+ case 'pull':
+ return (new PullCommand($workspace, $environment))->execute($options);
+ case 'configure':
+ return $configure->execute($options);
+ case 'render':
+ return (new RenderCommand($workspace, $environment, $configure))->execute($options);
+ case 'cs':
+ return (new LintCommand($workspace, $environment, $configure, fix: $subcommand === 'fix'))
+ ->execute($options);
+ case 'serve':
+ return (new ServeCommand($workspace, $environment))->execute($options);
+ case 'docker':
+ return $subcommand === 'build'
+ ? (new BuildCommand($environment))->execute($options)
+ : (new ShellCommand($environment))->execute($options);
+ }
+
+ fwrite(STDERR, "Unknown command: $command (see: php dev.php help)\n");
+
+ return 1;
+ }
+
+ /** @param list $args */
+ private function parse(array $args, Options $options): ?string
+ {
+ $command = null;
+
+ foreach ($args as $arg) {
+ if (preg_match('/^--lang=(.+)$/', $arg, $m)) {
+ $options->lang = $m[1];
+ continue;
+ }
+
+ if (preg_match('/^--port=(\d+)$/', $arg, $m)) {
+ $options->port = (int) $m[1];
+ continue;
+ }
+
+ if ($arg === '--docker') {
+ $options->docker = true;
+ continue;
+ }
+
+ if ($arg === '--no-docker') {
+ $options->docker = false;
+ continue;
+ }
+
+ if ($arg === '--yes' || $arg === '-y') {
+ $options->assumeYes = true;
+ continue;
+ }
+
+ if ($command === null) {
+ if ($arg[0] !== '-') {
+ $command = $arg;
+ continue;
+ }
+
+ if ($arg === '-h' || $arg === '--help') {
+ $command = 'help';
+ continue;
+ }
+
+ $fileName = $_SERVER['SCRIPT_FILENAME'];
+ fwrite(STDERR, "Unknown option: $arg (see: php $fileName help)\n");
+ return null;
+ }
+
+ $options->args[] = $arg;
+ }
+
+ return $command ?? 'help';
+ }
+
+ private function requirePhpVersion(int $minimum): bool
+ {
+ if (PHP_VERSION_ID >= $minimum) {
+ return true;
+ }
+
+ $need = sprintf('%d.%d', intdiv($minimum, 10000), intdiv($minimum % 10000, 100));
+ fwrite(STDERR, "error: dev.php requires PHP $need+ without Docker (this is PHP "
+ . PHP_VERSION . "). Install Docker or a newer PHP.\n");
+
+ return false;
+ }
+}
diff --git a/scripts/dev/Command/BuildCommand.php b/scripts/dev/Command/BuildCommand.php
new file mode 100644
index 0000000000..eae7883836
--- /dev/null
+++ b/scripts/dev/Command/BuildCommand.php
@@ -0,0 +1,20 @@
+environment->buildImage();
+ }
+}
diff --git a/scripts/dev/Command/Command.php b/scripts/dev/Command/Command.php
new file mode 100644
index 0000000000..e3e0866727
--- /dev/null
+++ b/scripts/dev/Command/Command.php
@@ -0,0 +1,12 @@
+lang;
+
+ if (!$this->workspace->ensureLangRepos($lang, $this->environment->canMapDirectoryNames())) {
+ return 1;
+ }
+
+ $this->workspace->pullSideRepos($lang);
+
+ $args = array_merge([
+ ($this->workspace->isBaseLang($lang) ? '--with-base-lang=' : '--with-lang=') . $lang,
+ '--enable-xml-details',
+ '--disable-libxml-check',
+ '--redirect-stderr-to-stdout',
+ ], $options->args);
+
+ return $this->environment->configure($lang, $args);
+ }
+}
diff --git a/scripts/dev/Command/HelpCommand.php b/scripts/dev/Command/HelpCommand.php
new file mode 100644
index 0000000000..55ea9ce31f
--- /dev/null
+++ b/scripts/dev/Command/HelpCommand.php
@@ -0,0 +1,50 @@
+ [options] [extra arguments]
+
+ Commands:
+ pull Clone missing sibling repositories and update existing ones
+ configure Assemble and validate the manual, without rendering
+ render xhtml configure + render the chunked XHTML manual to /output
+ render php configure + render the web (PHP) version to /output
+ cs lint Run docbook-cs; extra arguments are passed through (paths, --wide)
+ cs fix Same as cs lint, with --fix: rewrite violations that have fixers
+ serve Serve /output over HTTP
+ docker build Build the Docker image
+ docker shell Interactive shell inside the container
+
+ Options:
+ --lang=XX Language to operate on (default: en)
+ --port=NNNN Port for serve (default: 8080)
+ --docker Force Docker mode (default: used when available)
+ --no-docker Force local mode
+ --yes, -y Clone missing repositories without asking for confirmation
+
+ Any other argument after the command is passed through: to configure.php
+ for configure/render (e.g. --enable-xml-details), and to
+ docbook-cs for cs lint/cs fix (e.g. reference/datetime --wide).
+
+ HELP;
+
+ return 0;
+ }
+}
diff --git a/scripts/dev/Command/LintCommand.php b/scripts/dev/Command/LintCommand.php
new file mode 100644
index 0000000000..b9ea6346bb
--- /dev/null
+++ b/scripts/dev/Command/LintCommand.php
@@ -0,0 +1,57 @@
+lang;
+
+ if (!$this->workspace->ensureLangRepos($lang, $this->environment->canMapDirectoryNames())) {
+ return 1;
+ }
+
+ if ($this->configure->execute($options) !== 0) {
+ echo "\nconfigure reported problems (see above); linting anyway.\n\n";
+ }
+
+ $langdir = $this->workspace->langDir($lang);
+ $args = $options->args;
+
+ if ($this->fix) {
+ array_unshift($args, '--fix');
+ }
+
+ if (!file_exists("$langdir/docbookcs.xml")) {
+ $template = $this->workspace->getDocbookcsConfig();
+
+ if ($template === null) {
+ return 1;
+ }
+
+ $config = "$langdir/.docbookcs.dev.xml";
+ file_put_contents($config, str_replace('@LANG@', $lang, $template));
+ register_shutdown_function(static function () use ($config): void {
+ @unlink($config);
+ });
+ array_unshift($args, '--config=.docbookcs.dev.xml');
+ }
+
+ return $this->environment->lint($lang, $args);
+ }
+}
diff --git a/scripts/dev/Command/PullCommand.php b/scripts/dev/Command/PullCommand.php
new file mode 100644
index 0000000000..8992f6a209
--- /dev/null
+++ b/scripts/dev/Command/PullCommand.php
@@ -0,0 +1,32 @@
+environment->canMapDirectoryNames();
+
+ if (!$this->workspace->ensureLangRepos($options->lang, $mapNames)) {
+ return 1;
+ }
+
+ $this->workspace->pullSideRepos($options->lang, verbose: true);
+ $this->environment->refreshTools();
+
+ return 0;
+ }
+}
diff --git a/scripts/dev/Command/RenderCommand.php b/scripts/dev/Command/RenderCommand.php
new file mode 100644
index 0000000000..b594fdc339
--- /dev/null
+++ b/scripts/dev/Command/RenderCommand.php
@@ -0,0 +1,43 @@
+configure->execute($options);
+
+ if ($ret !== 0) {
+ return $ret;
+ }
+
+ $lang = $options->lang;
+ $format = $options->format;
+
+ // PhD never cleans its output directory, so files from removed or
+ // renamed pages would linger forever. Remove utput tree before rendering.
+ $stale = $this->workspace->langDir($lang) . '/output/'
+ . ($format === 'php' ? 'php-web' : 'php-chunked-xhtml');
+
+ if (is_dir($stale)) {
+ echo "Removing previous $stale\n";
+ $this->workspace->removeTree($stale);
+ }
+
+ return $this->environment->render($lang, $format);
+ }
+}
diff --git a/scripts/dev/Command/ServeCommand.php b/scripts/dev/Command/ServeCommand.php
new file mode 100644
index 0000000000..83b5a768a2
--- /dev/null
+++ b/scripts/dev/Command/ServeCommand.php
@@ -0,0 +1,37 @@
+lang;
+ $output = $this->workspace->langDir($lang) . '/output';
+
+ if (!is_dir($output)) {
+ echo "Note: $output does not exist yet; run \"php dev.php render xhtml --lang=$lang\" first.\n";
+ }
+
+ // PhD renders each format into its own subdirectory of output/.
+ // Serve the chunked XHTML tree directly, so http://localhost:/
+ // lands on its index page instead of a 404.
+ $subdir = is_dir("$output/php-chunked-xhtml") ? '/php-chunked-xhtml' : '';
+
+ echo "Serving the $lang manual at http://localhost:{$options->port}/ (Ctrl-C to stop)\n";
+
+ return $this->environment->serve($lang, $options->port, $subdir);
+ }
+}
diff --git a/scripts/dev/Command/ShellCommand.php b/scripts/dev/Command/ShellCommand.php
new file mode 100644
index 0000000000..f7f9e60ea5
--- /dev/null
+++ b/scripts/dev/Command/ShellCommand.php
@@ -0,0 +1,20 @@
+environment->shell($options->lang);
+ }
+}
diff --git a/scripts/dev/Environment/DockerEnvironment.php b/scripts/dev/Environment/DockerEnvironment.php
new file mode 100644
index 0000000000..d1859708e5
--- /dev/null
+++ b/scripts/dev/Environment/DockerEnvironment.php
@@ -0,0 +1,240 @@
+ensureImage()) {
+ return 1;
+ }
+
+ return $this->dockerRun($lang, array_merge(['php', 'doc-base/configure.php'], $args));
+ }
+
+ public function render(string $lang, string $format): int
+ {
+ if (!$this->ensureImage()) {
+ return 1;
+ }
+
+ return $this->dockerRun($lang, [
+ 'php',
+ 'phd/render.php',
+ '--docbook',
+ 'doc-base/.manual.xml',
+ '--output=/var/www/' . $lang . '/output',
+ '--package',
+ 'PHP',
+ '--format',
+ $format,
+ ]);
+ }
+
+ public function lint(string $lang, array $args): int
+ {
+ if (!$this->ensureImage()) {
+ return 1;
+ }
+
+ return $this->dockerRun(
+ $lang,
+ array_merge(['php', '/var/www/docbook-cs/bin/docbook-cs'], $args),
+ [
+ '-e',
+ 'GIT_CONFIG_COUNT=1',
+ '-e',
+ 'GIT_CONFIG_KEY_0=safe.directory',
+ '-e',
+ 'GIT_CONFIG_VALUE_0=*',
+ ],
+ "/var/www/$lang"
+ );
+ }
+
+ public function serve(string $lang, int $port, string $subdir): int
+ {
+ if (!$this->ensureImage()) {
+ return 1;
+ }
+
+ // Inside the container the server must bind 0.0.0.0 to be reachable
+ // through the published port; the host side stays localhost-only.
+ return $this->dockerRun(
+ $lang,
+ ['php', '-S', "0.0.0.0:$port", '-t', "/var/www/$lang/output$subdir"],
+ ['-p', "127.0.0.1:$port:$port"]
+ );
+ }
+
+ public function shell(string $lang): int
+ {
+ if (!$this->ensureImage()) {
+ return 1;
+ }
+
+ return $this->dockerRun($lang, ['bash'], ['-it']);
+ }
+
+ public function buildImage(): int
+ {
+ return $this->build() ? 0 : 1;
+ }
+
+ public function refreshTools(): void
+ {
+ // Host checkouts are mounted over the image's copies, so only a
+ // rebuild can update tools that ship inside the image.
+ if ($this->usesBakedTools()) {
+ $this->ensureImage();
+ }
+ }
+
+ private function ensureImage(): bool
+ {
+ if (!$this->imageReady) {
+ $this->imageReady = $this->prepareImage();
+ }
+
+ return $this->imageReady;
+ }
+
+ private function prepareImage(): bool
+ {
+ $dockerfile = $this->workspace->basedir() . '/.docker/Dockerfile';
+ $stamp = $this->workspace->basedir() . '/.docker/built';
+
+ $id = $this->runner->output(['docker', 'image', 'inspect', '--format', '{{.Id}}', self::IMAGE]);
+
+ $usable = $id !== null
+ && file_exists($stamp)
+ && filemtime($stamp) >= filemtime($dockerfile);
+
+ if (!$usable) {
+ return $this->build();
+ }
+
+ if (!$this->usesBakedTools()) {
+ return true;
+ }
+
+ if (!$this->build(quiet: true)) {
+ echo "Could not refresh the doc-dev image; using the current one.\n";
+ return true;
+ }
+
+ if ($this->runner->output(['docker', 'image', 'inspect', '--format', '{{.Id}}', self::IMAGE]) !== $id) {
+ echo "Updated phd/docbook-cs (rebuilt the doc-dev image).\n";
+ }
+
+ return true;
+ }
+
+ private function usesBakedTools(): bool
+ {
+ $root = $this->workspace->rootdir();
+
+ return !is_dir("$root/phd") || !is_dir("$root/docbook-cs");
+ }
+
+ private function build(bool $quiet = false): bool
+ {
+ $cmd = ['docker', 'build'];
+ $ids = $this->unixIds();
+
+ if ($ids !== null) {
+ array_push($cmd, '--build-arg', 'UID=' . $ids[0]);
+ array_push($cmd, '--build-arg', 'GID=' . $ids[1]);
+ }
+
+ array_push($cmd, '-t', self::IMAGE, $this->workspace->basedir() . '/.docker');
+
+ $status = $quiet ? $this->runner->runQuiet($cmd) : $this->runner->run($cmd);
+
+ if ($status !== 0) {
+ return false;
+ }
+
+ touch($this->workspace->basedir() . '/.docker/built');
+
+ return true;
+ }
+
+ private function mounts(string $lang): array
+ {
+ $root = $this->workspace->rootdir();
+ $mounts = [realpath($this->workspace->basedir()) => '/var/www/doc-base'];
+
+ $mounts[$this->workspace->langDir($lang)] = "/var/www/$lang";
+
+ if (!$this->workspace->isBaseLang($lang)) {
+ $mounts[$this->workspace->langDir('en')] = '/var/www/en';
+ }
+
+ foreach (['phd', 'docbook-cs'] as $tool) {
+ if (is_dir("$root/$tool")) {
+ $mounts[realpath("$root/$tool")] = "/var/www/$tool";
+ }
+ }
+
+ return $mounts;
+ }
+
+ /**
+ * @param list $inner Command to run inside the container.
+ * @param list $extra Extra docker run arguments.
+ */
+ private function dockerRun(string $lang, array $inner, array $extra = [], string $workdir = '/var/www'): int
+ {
+ // --init: without it the command runs as PID 1, which ignores
+ // SIGINT, so Ctrl-C would leave the container running forever.
+ $cmd = ['docker', 'run', '--rm', '--init'];
+
+ foreach ($this->mounts($lang) as $host => $container) {
+ array_push($cmd, '-v', "$host:$container");
+ }
+
+ array_push($cmd, '-w', $workdir);
+ $ids = $this->unixIds();
+
+ if ($ids !== null) {
+ array_push($cmd, '-u', $ids[0] . ':' . $ids[1]);
+ }
+
+ $cmd = array_merge($cmd, $extra);
+ $cmd[] = self::IMAGE;
+
+ return $this->runner->run(array_merge($cmd, $inner));
+ }
+
+ /** @return array{int, int}|null */
+ private function unixIds(): ?array
+ {
+ if (function_exists('posix_getuid')) {
+ return [posix_getuid(), posix_getgid()];
+ }
+
+ return null;
+ }
+}
diff --git a/scripts/dev/Environment/Environment.php b/scripts/dev/Environment/Environment.php
new file mode 100644
index 0000000000..998ec006ab
--- /dev/null
+++ b/scripts/dev/Environment/Environment.php
@@ -0,0 +1,35 @@
+ checkout can be presented under its language
+ * name without touching the filesystem (Docker mounts can).
+ */
+ public function canMapDirectoryNames(): bool;
+
+ /** @param list $args configure.php arguments */
+ public function configure(string $lang, array $args): int;
+
+ public function render(string $lang, string $format): int;
+
+ /** @param list $args docbook-cs arguments; runs in the language directory */
+ public function lint(string $lang, array $args): int;
+
+ /** @param string $subdir Path inside /output to use as web root, or "". */
+ public function serve(string $lang, int $port, string $subdir): int;
+
+ public function shell(string $lang): int;
+
+ public function buildImage(): int;
+
+ public function refreshTools(): void;
+}
diff --git a/scripts/dev/Environment/LocalEnvironment.php b/scripts/dev/Environment/LocalEnvironment.php
new file mode 100644
index 0000000000..8e98663de8
--- /dev/null
+++ b/scripts/dev/Environment/LocalEnvironment.php
@@ -0,0 +1,97 @@
+runner->run(
+ array_merge([PHP_BINARY, $this->workspace->basedir() . '/configure.php'], $args),
+ $this->workspace->rootdir()
+ );
+ }
+
+ public function render(string $lang, string $format): int
+ {
+ $root = $this->workspace->rootdir();
+
+ if (!$this->workspace->ensureRepo("$root/phd", 'https://github.com/php/phd.git')) {
+ return 1;
+ }
+
+ return $this->runner->run([
+ PHP_BINARY,
+ "$root/phd/render.php",
+ '--docbook',
+ $this->workspace->basedir() . '/.manual.xml',
+ '--output=' . $this->workspace->langDir($lang) . '/output',
+ '--package',
+ 'PHP',
+ '--format',
+ $format,
+ ], $root);
+ }
+
+ public function lint(string $lang, array $args): int
+ {
+ $csdir = $this->workspace->rootdir() . '/docbook-cs';
+
+ if (!$this->workspace->ensureRepo($csdir, 'https://github.com/php/docbook-cs.git')) {
+ return 1;
+ }
+
+ return $this->runner->run(
+ array_merge([PHP_BINARY, "$csdir/bin/docbook-cs"], $args),
+ $this->workspace->langDir($lang),
+ );
+ }
+
+ public function serve(string $lang, int $port, string $subdir): int
+ {
+ return $this->runner->run([
+ PHP_BINARY,
+ '-S',
+ "localhost:$port",
+ '-t',
+ $this->workspace->langDir($lang) . '/output' . $subdir,
+ ]);
+ }
+
+ public function shell(string $lang): int
+ {
+ fwrite(STDERR, "error: docker shell requires Docker.\n");
+
+ return 1;
+ }
+
+ public function buildImage(): int
+ {
+ fwrite(STDERR, "error: docker build requires Docker.\n");
+
+ return 1;
+ }
+
+ public function refreshTools(): void
+ {
+ // Intentionally left empty, Workspace::pullSideRepos() already updates those.
+ }
+}
diff --git a/scripts/dev/Options.php b/scripts/dev/Options.php
new file mode 100644
index 0000000000..6aa0fd43d6
--- /dev/null
+++ b/scripts/dev/Options.php
@@ -0,0 +1,21 @@
+ */
+ public array $args = [];
+}
diff --git a/scripts/dev/ProcessRunner.php b/scripts/dev/ProcessRunner.php
new file mode 100644
index 0000000000..c5017aa190
--- /dev/null
+++ b/scripts/dev/ProcessRunner.php
@@ -0,0 +1,63 @@
+ $cmd
+ * @param array $env Extra environment variables.
+ */
+ public function run(array $cmd, ?string $cwd = null, array $env = []): int
+ {
+ $envp = $env === [] ? null : array_merge(getenv(), $env);
+ $proc = @proc_open($cmd, [STDIN, STDOUT, STDERR], $pipes, $cwd, $envp);
+
+ if (!is_resource($proc)) {
+ fwrite(STDERR, "error: failed to execute {$cmd[0]}.\n");
+ return 127;
+ }
+
+ return proc_close($proc);
+ }
+
+ /** @param list $cmd */
+ public function output(array $cmd, ?string $cwd = null): ?string
+ {
+ $spec = [['pipe', 'r'], ['pipe', 'w'], ['pipe', 'w']];
+ $proc = @proc_open($cmd, $spec, $pipes, $cwd);
+
+ if (!is_resource($proc)) {
+ return null;
+ }
+
+ fclose($pipes[0]);
+ $stdout = stream_get_contents($pipes[1]);
+ fclose($pipes[1]);
+ stream_get_contents($pipes[2]);
+ fclose($pipes[2]);
+
+ return proc_close($proc) === 0 ? (string) $stdout : null;
+ }
+
+ /** @param list $cmd */
+ public function runQuiet(array $cmd): int
+ {
+ $spec = [['pipe', 'r'], ['pipe', 'w'], ['pipe', 'w']];
+ $proc = @proc_open($cmd, $spec, $pipes);
+
+ if (!is_resource($proc)) {
+ return 127; /* command not found */
+ }
+
+ fclose($pipes[0]);
+ stream_get_contents($pipes[1]);
+ fclose($pipes[1]);
+ stream_get_contents($pipes[2]);
+ fclose($pipes[2]);
+
+ return proc_close($proc);
+ }
+}
diff --git a/scripts/dev/Workspace.php b/scripts/dev/Workspace.php
new file mode 100644
index 0000000000..6c167e8d05
--- /dev/null
+++ b/scripts/dev/Workspace.php
@@ -0,0 +1,213 @@
+basedir;
+ }
+
+ public function rootdir(): string
+ {
+ return dirname($this->basedir);
+ }
+
+ public function isBaseLang(string $lang): bool
+ {
+ return in_array($lang, ['extensions', 'en'], true);
+ }
+
+ public function langDir(string $lang): string
+ {
+ $root = $this->rootdir();
+
+ if (is_dir("$root/$lang")) {
+ return realpath("$root/$lang");
+ }
+
+ return realpath("$root/doc-$lang") ?: "$root/$lang";
+ }
+
+ public function ensureRepo(string $dir, string $url): bool
+ {
+ if (is_dir($dir)) {
+ return true;
+ }
+
+ if (!$this->confirm("Clone $url\n into $dir?")) {
+ fwrite(STDERR, "error: cannot continue without $dir.\n");
+ return false;
+ }
+
+ return $this->runner->run(['git', 'clone', $url, $dir]) === 0;
+ }
+
+ /**
+ * @param bool $mapNames Whether the environment can present a
+ * doc- checkout under its language name
+ * (Docker mounts can, local builds cannot).
+ */
+ public function ensureLang(string $lang, bool $mapNames): bool
+ {
+ $root = $this->rootdir();
+
+ if (is_dir("$root/$lang")) {
+ return true;
+ }
+
+ if (is_dir("$root/doc-$lang")) {
+ if ($mapNames) {
+ return true;
+ }
+
+ if (@symlink("doc-$lang", "$root/$lang")) {
+ echo "Created symlink $lang -> doc-$lang\n";
+ return true;
+ }
+
+ fwrite(STDERR, "error: found $root/doc-$lang but could not create a '$lang' "
+ . "symlink next to it; rename the directory to '$lang' or use Docker.\n");
+ return false;
+ }
+
+ $repo = 'doc-' . strtolower($lang);
+
+ return $this->ensureRepo("$root/$lang", "https://github.com/php/$repo.git");
+ }
+
+ public function ensureLangRepos(string $lang, bool $mapNames): bool
+ {
+ if (!$this->isBaseLang($lang) && !$this->ensureLang('en', $mapNames)) {
+ return false;
+ }
+
+ if ($lang === 'en') {
+ return $this->ensureLang('en', $mapNames);
+ }
+
+ return $this->ensureLang($lang, $mapNames);
+ }
+
+ public function pullSideRepos(string $lang, bool $verbose = false): void
+ {
+ $root = $this->rootdir();
+ $repos = [$this->basedir, $this->langDir('en')];
+
+ if ($lang !== 'en') {
+ $repos[] = $this->langDir($lang);
+ }
+
+ foreach (['phd', 'docbook-cs'] as $tool) {
+ if (is_dir("$root/$tool")) {
+ $repos[] = realpath("$root/$tool");
+ }
+ }
+
+ foreach ($repos as $repo) {
+ $this->pullRepo($repo, $verbose);
+ }
+ }
+
+ private function pullRepo(string $dir, bool $verbose): void
+ {
+ if (!is_dir("$dir/.git")) {
+ return;
+ }
+
+ $name = basename($dir);
+ $branch = trim((string) $this->runner->output(['git', '-C', $dir, 'rev-parse', '--abbrev-ref', 'HEAD']));
+
+ if (!in_array($branch, ['master', 'main'], true)) {
+ if ($verbose) {
+ echo "Not updating $name: on branch '$branch'.\n";
+ }
+
+ return;
+ }
+
+ $before = $this->runner->output(['git', '-C', $dir, 'rev-parse', 'HEAD']);
+
+ if ($this->runner->run(['git', '-C', $dir, 'pull', '--ff-only', '--quiet', 'origin', $branch]) !== 0) {
+ echo "Could not update $name; continuing with the current checkout.\n";
+ return;
+ }
+
+ $after = $this->runner->output(['git', '-C', $dir, 'rev-parse', 'HEAD']);
+
+ if ($before !== $after) {
+ echo "Updated $name.\n";
+ } elseif ($verbose) {
+ echo "$name is up to date.\n";
+ }
+ }
+
+ public function removeTree(string $dir): void
+ {
+ $items = new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
+ RecursiveIteratorIterator::CHILD_FIRST
+ );
+
+ foreach ($items as $item) {
+ if ($item->isDir() && !$item->isLink()) {
+ rmdir($item->getPathname());
+ } else {
+ unlink($item->getPathname());
+ }
+ }
+
+ rmdir($dir);
+ }
+
+ public function getDocbookcsConfig(): ?string
+ {
+ $template = $this->basedir . '/docbookcs.dev.xml';
+ $config = @file_get_contents($template);
+
+ if ($config === false) {
+ fwrite(STDERR, "error: cannot read $template.\n");
+ return null;
+ }
+
+ return $config;
+ }
+
+ private function confirm(string $question): bool
+ {
+ if ($this->assumeYes) {
+ return true;
+ }
+
+ if (!stream_isatty(STDIN)) {
+ fwrite(STDERR, "error: confirmation needed but there is no terminal; re-run with --yes.\n");
+ return false;
+ }
+
+ echo $question . ' [Y/n] ';
+ $line = fgets(STDIN);
+
+ // EOF (Ctrl-D, closed stdin) is not consent.
+ if ($line === false) {
+ echo "\n";
+ return false;
+ }
+
+ $answer = strtolower(trim($line));
+ return in_array($answer, ['', 'y', 'yes']);
+ }
+}